PONYλM2Modula-2

Java.CodeCompared.To/TypeScript

An interactive executable cheatsheet comparing Java and TypeScript

Java 25 TypeScript 6.0.3
Output & Running
Hello, World
A TypeScript file is a list of statements. There is no class wrapper, no main method to find, and no entry-point convention — the first line of the file is the first line that runs.
class Main { public static void main(String[] args) { System.out.println("Hello, World!"); } }
console.log("Hello, World!");
Every piece of ceremony Java requires — the class, the public static void main signature, the String[] args parameter — has no TypeScript counterpart. There is still a compile step, but it produces JavaScript rather than bytecode, and it is the JavaScript that runs.
Interpolating values into text
A template literal is written with backticks, and ${...} holds any expression at all. There is no format string, so there are no %s/%d placeholders to line up with arguments.
class Main { public static void main(String[] args) { String name = "Ada"; int year = 1843; System.out.println("Hello, %s — %d".formatted(name, year)); } }
const name = "Ada"; const year = 1843; console.log(`Hello, ${name} — ${year}`);
The expression inside ${...} is type-checked, and its result is converted to text with the same rules as string concatenation. What you lose is width and precision control: there is no %.2f, so formatting a number means calling toFixed or toLocaleString on it first.
Multi-line text
The same backtick literal spans lines. Unlike a Java text block, it has no incidental-indentation rule — every space between the backticks is part of the string, so the content is written flush left.
class Main { public static void main(String[] args) { String message = """ Dear reader, indented once. Yours, Java"""; System.out.println(message); } }
const message = `Dear reader, indented once. Yours, TypeScript`; console.log(message);
A Java text block strips the common indentation of its lines so the literal can sit at the indentation of the surrounding code. A template literal does not, which is why multi-line text in TypeScript is usually written against the left margin even inside a deeply nested block.
Variables & Types
const and let
const is the default and let is the exception — the reverse of Java, where the plain declaration is mutable and final is the addition. The keyword goes before the name, and the type after it.
class Main { public static void main(String[] args) { final int limit = 10; int count = 0; count = count + 1; System.out.println(limit + " " + count); } }
const limit = 10; let count = 0; count = count + 1; console.log(limit, count);
const forbids rebinding, exactly as final does — it says nothing about the value itself. A const array can still be pushed to and a const object can still have its properties reassigned, just as a final List reference can still be added to.
Where the type goes
The annotation follows the name after a colon, and the primitive names are lowercase: number, string, boolean. There is no separate boxed form and no int/long/double distinction.
class Main { public static void main(String[] args) { int count = 42; String label = "items"; double ratio = 0.5; boolean ready = true; System.out.println(count + " " + label + " " + ratio + " " + ready); } }
const count: number = 42; const label: string = "items"; const ratio: number = 0.5; const ready: boolean = true; console.log(count, label, ratio, ready);
Writing these annotations out is legal but unusual: TypeScript infers all four from the initializers, and the house style is to annotate only where inference would be wrong or unhelpful. The types themselves are compile-time only — the emitted JavaScript is four bare const declarations.
const infers a narrower type than let
Java's var infers one type per initializer. TypeScript infers two, and which one you get depends on whether the binding can change: a const that can never be reassigned gets the type of that exact value.
class Main { public static void main(String[] args) { var method = "GET"; // String, always var verb = "GET"; // String, always verb = "POST"; System.out.println(method + " " + verb); } }
const method = "GET"; // type is "GET" — that one string let verb = "GET"; // type is string — any string verb = "POST"; console.log(method, verb);
The type "GET" is a literal type: a type whose only value is that string. It is what makes "GET" | "POST" a usable stand-in for an enum, and it is why passing a let-bound string where such a union is expected fails — the compiler already widened it to string.
any, unknown, and Object
There are two escape hatches, and only one is safe. any switches checking off for that value entirely; unknown accepts anything but permits nothing until you have narrowed it. unknown is the honest translation of Java's Object.
class Main { public static void main(String[] args) { Object value = "hello"; // value.length() — rejected: Object has no length() String text = (String) value; System.out.println(text.length()); } }
const value: unknown = "hello"; // value.length — rejected: value is unknown if (typeof value === "string") { console.log(value.length); } const loose: any = "hello"; console.log(loose.length); // allowed, and unchecked
A Java cast is verified by the virtual machine, so a wrong one throws a ClassCastException at the point of the mistake. TypeScript has no runtime check to fall back on, so unknown forces the test into your code — and any lets you skip both the test and the error, which is why it is the thing to keep out of a codebase.
Numbers & Math
One number type
There is a single numeric type, number, and it is an IEEE-754 double. Every int, long, float and double in a Java program maps onto it, which means whole numbers are exact only up to 2⁵³.
class Main { public static void main(String[] args) { int whole = 7; long large = 9007199254740993L; double fractional = 7.0 / 2; System.out.println(whole + " " + large + " " + fractional); } }
const whole = 7; const large = 9007199254740993; // silently stored as ...992 const fractional = 7 / 2; console.log(whole, large, fractional);
The literal 9007199254740993 has no exact double representation, so it is rounded as it is read and prints back one less. Nothing warns you: there is no compile error, no overflow, and no long to move to. This is the single most surprising thing about carrying identifiers from a Java database into TypeScript.
Division does not truncate
With no integer type there is no integer division. 7 / 2 is 3.5, and truncating is an explicit call.
class Main { public static void main(String[] args) { System.out.println(7 / 2); System.out.println(7 % 2); System.out.println(-7 / 2); } }
console.log(7 / 2); console.log(7 % 2); console.log(-7 / 2); console.log(Math.trunc(7 / 2)); // 3 — what Java's 7 / 2 gives
The remainder operator agrees with Java in taking the sign of the dividend, so -7 % 2 is -1 in both. It is only the quotient that differs — and because the result is still a valid number, an accidental 3.5 travels a long way before anything notices.
bigint for exact whole numbers
When 2⁵³ is not enough, the answer is bigint — a separate primitive written with an n suffix. It is arbitrary-precision like BigInteger, but unlike BigInteger it has the ordinary operators.
import java.math.BigInteger; class Main { public static void main(String[] args) { System.out.println(Long.MAX_VALUE); System.out.println(Long.MAX_VALUE + 1); // wraps around BigInteger exact = new BigInteger("9007199254740991").add(BigInteger.TWO); System.out.println(exact); } }
console.log(Number.MAX_SAFE_INTEGER); console.log(Number.MAX_SAFE_INTEGER + 2); // loses precision const exact = 9007199254740991n + 2n; console.log(exact.toString());
A long that overflows wraps to a negative number; a number that runs past 2⁵³ starts skipping values instead. Neither is checked, but they fail differently. A bigint and a number cannot be mixed in one expression — 1n + 1 is a TypeError — which is the language forcing the conversion to be deliberate.
Rounding and fixed decimals
Math.round behaves exactly as Java's does — halves go toward positive infinity, so 2.5 rounds to 3 and -2.5 rounds to -2. Fixed-decimal output comes from toFixed, which returns a string.
class Main { public static void main(String[] args) { System.out.println(Math.round(2.5)); System.out.println(Math.round(-2.5)); System.out.println("%.2f".formatted(1.0 / 3)); System.out.println(Math.max(3, 7) + " " + Math.abs(-4)); } }
console.log(Math.round(2.5)); console.log(Math.round(-2.5)); console.log((1 / 3).toFixed(2)); console.log(Math.max(3, 7), Math.abs(-4));
Because toFixed hands back a string, chaining more arithmetic onto it silently concatenates instead of adding. The Math namespace otherwise lines up closely with java.lang.Math, with ** available as an operator where Java has only Math.pow.
Strings
Building a string piece by piece
Strings are immutable here too, but there is no StringBuilder. The idiom is to collect the pieces in an array and join them at the end.
class Main { public static void main(String[] args) { StringBuilder builder = new StringBuilder(); for (String word : new String[] { "type", "script" }) { builder.append(word); } System.out.println(builder.toString()); } }
const parts: string[] = []; for (const word of ["type", "script"]) { parts.push(word); } console.log(parts.join(""));
Repeated += in a loop also works and is heavily optimized by every engine, so the array-and-join form is chosen for readability rather than speed. What is genuinely absent is a builder object you can pass around and append to from several places.
The everyday string methods
Most of the names carry over with a spelling change: strip is trim, substring is slice, and String.join moves onto the array as join.
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); } }
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);
slice is more forgiving than substring: it accepts negative indices to count from the end, and an out-of-range index clamps rather than throwing. That means a slicing bug shows up as a short string rather than a StringIndexOutOfBoundsException.
There is no char
Indexing a string gives back a string of length one, not a numeric code unit. There is no char type, so nothing arithmetic happens by accident.
class Main { public static void main(String[] args) { String word = "héllo"; char first = word.charAt(0); System.out.println(first); System.out.println((int) first); System.out.println(word.length()); } }
const word = "héllo"; const first = word[0]; console.log(first); console.log(word.codePointAt(0)); console.log(word.length);
Both languages measure length in UTF-16 code units, so an emoji counts as two in each. The difference is that word.charAt(0) + 1 is arithmetic in Java and concatenation in TypeScript — the char-is-a-number trap simply does not exist.
Comparing strings
This is the Java lesson in reverse. === compares strings by their contents, so the habit of never comparing strings with == is the wrong habit here.
class Main { public static void main(String[] args) { String left = new String("same"); String right = "same"; System.out.println(left == right); System.out.println(left.equals(right)); } }
const left = ["s", "a", "m", "e"].join(""); const right = "same"; console.log(left === right); console.log(left.localeCompare(right) === 0);
Strings are primitives, so === is a value comparison and there is no equals to call. Objects are the opposite: === on two objects is reference identity and there is no equals for them either, so structural comparison has to be written by hand.
Arrays
One array type, and it grows
Java's two collection shapes — a fixed-length int[] and a resizable ArrayList — are one type here. An array literal is written with brackets, it grows on push, and its element type is part of its type.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { int[] fixed = { 1, 2, 3 }; List<Integer> growable = new ArrayList<>(List.of(1, 2, 3)); growable.add(4); System.out.println(fixed.length + " " + growable.size()); } }
const numbers: number[] = [1, 2, 3]; numbers.push(4); console.log(numbers.length); console.log(numbers.join(","));
The type is written number[] or, equivalently, Array<number>. Because there is no boxing, an array of numbers holds numbers rather than Integer objects — the int[] versus List<Integer> performance decision has nothing to decide.
sort compares as text by default
This is the trap that catches every Java developer once. Calling sort() with no argument converts each element to a string and compares those, so numbers come out in dictionary order.
import java.util.Arrays; class Main { public static void main(String[] args) { int[] numbers = { 10, 9, 100, 1 }; Arrays.sort(numbers); System.out.println(Arrays.toString(numbers)); } }
const numbers = [10, 9, 100, 1]; numbers.sort(); console.log(numbers.join(",")); // 1,10,100,9 numbers.sort((left, right) => left - right); console.log(numbers.join(",")); // 1,9,10,100
The comparator is a plain function returning a negative number, zero, or a positive number — the same contract as Comparator.compare, without the interface. Note also that sort mutates the array in place and returns it; toSorted is the copying version.
Destructuring and spread
Square brackets on the left of an assignment pull elements out by position, and ... collects whatever is left. The same ... on the right expands an array into another one.
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)); } }
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(","));
Java 21 brought record patterns, which destructure a record's components — but nothing destructures an array or a list. Spread is a shallow copy, so [...values] duplicates the array while leaving any objects inside it shared.
readonly is a compile-time promise
readonly number[] removes the mutating methods from the type, so push is a compile error. It changes nothing at runtime — for that you need Object.freeze, which is a different tool.
import java.util.List; class Main { public static void main(String[] args) { List<Integer> frozen = List.of(1, 2, 3); try { frozen.add(4); } catch (UnsupportedOperationException error) { System.out.println("rejected at runtime"); } System.out.println(frozen.size()); } }
const frozen: readonly number[] = [1, 2, 3]; // frozen.push(4); // compile error: push is not on readonly number[] const alsoFrozen = Object.freeze([1, 2, 3]); try { (alsoFrozen as number[]).push(4); } catch (error) { console.log("rejected at runtime"); } console.log(frozen.length);
List.of gives you both guarantees at once: the compiler sees an immutable interface and the object throws if you get past it. TypeScript splits them, and the compile-time half is erased — a readonly array handed to plain JavaScript is an ordinary, fully mutable array.
Objects, Maps & Sets
The object literal
An object literal is the everyday data carrier, and it needs no declaration. Its type is inferred from its contents, so the properties are checked even though nothing named the shape.
import java.util.Map; class Main { public static void main(String[] args) { Map<String, Object> person = Map.of("name", "Ada", "born", 1815); String name = (String) person.get("name"); int born = (Integer) person.get("born"); System.out.println(name + " " + born); } }
const person = { name: "Ada", born: 1815 }; console.log(person.name, person.born); // person.borne — compile error: no such property
The inferred type is { name: string; born: number }, so person.born is a number with no cast and a misspelled property is a compile error. The Java stand-in, a Map<String, Object>, gives up both — every read needs a cast and every key is unchecked.
Map
The Map class is the direct counterpart of HashMap, with set/get/has/delete. It always iterates in insertion order — there is no hash-order versus LinkedHashMap choice to make.
import java.util.LinkedHashMap; import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> counts = new LinkedHashMap<>(); counts.put("apples", 1); counts.merge("apples", 1, Integer::sum); System.out.println(counts.get("apples")); System.out.println(counts.getOrDefault("pears", 0)); for (Map.Entry<String, Integer> entry : counts.entrySet()) { System.out.println(entry.getKey() + "=" + entry.getValue()); } } }
const counts = new Map<string, number>(); counts.set("apples", 1); counts.set("apples", (counts.get("apples") ?? 0) + 1); console.log(counts.get("apples")); console.log(counts.get("pears") ?? 0); for (const [fruit, count] of counts) { console.log(`${fruit}=${count}`); }
get returns number | undefined, so the missing case is part of the type and the compiler makes you deal with it — which is what getOrDefault exists to paper over. Keys are compared with ===, so there is no equals/hashCode to implement, and equally no way to key a map by value.
Set
A Set takes any iterable in its constructor, so deduplicating an array is one expression. Like Map, it always preserves insertion order.
import java.util.LinkedHashSet; import java.util.List; import java.util.Set; class Main { public static void main(String[] args) { Set<String> tags = new LinkedHashSet<>(List.of("draft", "urgent", "draft")); System.out.println(tags.size()); System.out.println(tags.contains("urgent")); System.out.println(String.join(",", tags)); } }
const tags = new Set(["draft", "urgent", "draft"]); console.log(tags.size); console.log(tags.has("urgent")); console.log([...tags].join(","));
Membership is ===, so two objects with identical contents are two separate members — a set of records that HashSet would collapse stays fully populated here. Spreading a set back into an array with [...tags] is the standard way to get at array methods.
An object as a dictionary
When the keys are strings and the values share a type, a plain object typed Record<string, number> is more idiomatic than a Map. Object.keys, Object.values and Object.entries are the iteration API.
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); int total = scores.values().stream().mapToInt(Integer::intValue).sum(); System.out.println(total); System.out.println(String.join(",", scores.keySet())); } }
const scores: Record<string, number> = { ada: 10, alan: 12 }; const total = Object.values(scores).reduce((sum, value) => sum + value, 0); console.log(total); console.log(Object.keys(scores).join(","));
These three helpers return arrays rather than the live views keySet and values give you, so they are snapshots. The catch is that Record<string, number> promises a number for every string key, so reading an absent one type-checks and hands back undefined.
Control Flow
Iterating
for...of is the enhanced for loop, and it works over anything iterable — arrays, strings, Map, Set. When the index is needed, entries() yields index-and-value pairs to destructure.
class Main { public static void main(String[] args) { String[] words = { "one", "two" }; for (String word : words) { System.out.println(word); } for (int index = 0; index < words.length; index++) { System.out.println(index + ":" + words[index]); } } }
const words = ["one", "two"]; for (const word of words) { console.log(word); } for (const [index, word] of words.entries()) { console.log(`${index}:${word}`); }
There is a second loop spelled for...in, and it is almost never what you want: it walks an object's property names, so over an array it yields "0", "1" — strings, not numbers, and inherited ones too. Reach for for...of unless you specifically want an object's keys.
Truthiness
An if accepts any value, not just a boolean. Six values are falsyfalse, 0, NaN, "", null, undefined — and everything else is truthy.
class Main { public static void main(String[] args) { String value = ""; if (value.isEmpty()) { System.out.println("empty"); } int count = 0; if (count == 0) { System.out.println("zero"); } } }
const value = ""; if (!value) { console.log("empty"); } const count = 0; if (!count) { console.log("zero"); }
The convenience is also the hazard: if (count) is wrong whenever 0 is a legitimate value, and if (text) is wrong whenever the empty string is. When you mean "was this supplied", write the comparison out — count !== undefined — rather than leaning on truthiness.
switch is a statement, not an expression
There is no arrow form and no switch expression: cases fall through unless you break, and the whole construct produces no value. A lookup object or a conditional expression usually reads better.
class Main { public static void main(String[] args) { int code = 2; String label = switch (code) { case 1 -> "one"; case 2 -> "two"; default -> "many"; }; System.out.println(label); } }
const code = 2; let label: string; switch (code) { case 1: label = "one"; break; case 2: label = "two"; break; default: label = "many"; } console.log(label); const labels: Record<number, string> = { 1: "one", 2: "two" }; console.log(labels[code] ?? "many");
Nothing checks that a switch over numbers covers every case, so the default is doing real work. Exhaustiveness checking does exist in TypeScript, but it arrives with discriminated unions rather than with switch itself — see the Unions section.
Functions
Functions are not in a class
A function is declared at the top level of a file. It is a value: it can be stored in a variable, passed to another function, and returned from one, with no interface wrapped around it.
class Main { static int doubled(int value) { return value * 2; } public static void main(String[] args) { System.out.println(doubled(21)); } }
function doubled(value: number): number { return value * 2; } console.log(doubled(21));
The return type is optional — TypeScript infers number here — but writing it is the usual habit, because it pins the function's contract instead of letting a change to the body quietly change the signature.
Function types replace functional interfaces
The type of a function is written inline as (value: number) => number. There is no Function, BiFunction, Supplier or IntUnaryOperator to pick from, and no single-abstract-method name to remember.
import java.util.function.IntUnaryOperator; class Main { public static void main(String[] args) { IntUnaryOperator doubled = value -> value * 2; System.out.println(doubled.applyAsInt(21)); } }
const doubled: (value: number) => number = (value) => value * 2; console.log(doubled(21));
Arity is part of the type rather than part of the interface's name, so a three-argument function needs no TriFunction that the standard library forgot to include. Calling it is ordinary call syntax — there is no apply, accept or get to look up.
Default values and rest parameters
A parameter can carry a default expression, which removes the reason for most overload pairs. ... before the last parameter collects the remaining arguments — Java's varargs, spelled differently.
class Main { static String greet(String name) { return greet(name, "Hello"); } static String greet(String name, String salutation) { return salutation + ", " + name; } static int sum(int... values) { int total = 0; for (int value : values) { total += value; } return total; } public static void main(String[] args) { System.out.println(greet("Ada")); System.out.println(greet("Ada", "Hi")); System.out.println(sum(1, 2, 3)); } }
function greet(name: string, salutation = "Hello"): string { return `${salutation}, ${name}`; } function sum(...values: number[]): number { return values.reduce((total, value) => total + value, 0); } console.log(greet("Ada")); console.log(greet("Ada", "Hi")); console.log(sum(1, 2, 3));
The default is an expression evaluated at call time, so it can refer to earlier parameters — function slice(text: string, end = text.length) is legal. Rest parameters arrive as a real array rather than as Java's array-that-might-also-have-been-passed-directly.
Overloads are signatures over one implementation
You can declare several signatures, but only one body follows them, and that body has to sort the cases out itself. There is no dispatch on argument type, because by the time the code runs the types are gone.
class Main { static String describe(int value) { return "number " + value; } static String describe(String value) { return "string " + value; } public static void main(String[] args) { System.out.println(describe(1)); System.out.println(describe("x")); } }
function describe(value: number): string; function describe(value: string): string; function describe(value: number | string): string { return typeof value === "number" ? `number ${value}` : `string ${value}`; } console.log(describe(1)); console.log(describe("x"));
The two declarations are what callers see; the third is the implementation and is not callable directly. Nothing checks that the body actually handles every declared signature, and there is no overloading on return type alone — the same limit Java has, for the opposite reason.
An object literal instead of a builder
Named arguments arrive as a single object parameter, destructured in the signature. A ? marks a property optional, and defaults are supplied in the destructuring pattern.
class Request { private final String url; private final String method; private final int timeoutSeconds; private Request(String url, String method, int timeoutSeconds) { this.url = url; this.method = method; this.timeoutSeconds = timeoutSeconds; } static Request of(String url) { return new Request(url, "GET", 30); } Request withMethod(String method) { return new Request(url, method, timeoutSeconds); } String describe() { return method + " " + url + " (" + timeoutSeconds + "s)"; } } class Main { public static void main(String[] args) { System.out.println(Request.of("/users").describe()); System.out.println(Request.of("/users").withMethod("POST").describe()); } }
function describe({ url, method = "GET", timeoutSeconds = 30 }: { url: string; method?: string; timeoutSeconds?: number; }): string { return `${method} ${url} (${timeoutSeconds}s)`; } console.log(describe({ url: "/users" })); console.log(describe({ url: "/users", method: "POST" }));
This is why builders are rare in TypeScript: the object literal already gives you named, order-independent, optional arguments, and the compiler still checks that url was supplied and that no unknown property was passed. What it does not give you is a partially-built value you can pass around before it is complete.
Structural Types
A type is a shape, not a name
This is the difference the rest of the page rests on. Nothing declares that the value below is a Named — it simply has the members Named describes, and in TypeScript that is what being a Named means.
interface Named { String name(); } record Person(String name) implements Named {} class Main { static void introduce(Named value) { System.out.println("I am " + value.name()); } public static void main(String[] args) { introduce(new Person("Ada")); } }
interface Named { name: string; } function introduce(value: Named): void { console.log(`I am ${value.name}`); } introduce({ name: "Ada" });
There is no implements clause to forget and no adapter class to write when a type you do not own happens to fit an interface you do. The cost is the mirror image: two unrelated types with the same shape are interchangeable, so Meters and Feet defined as { value: number } are the same type.
Extra properties, and the one place they are refused
A value with more members than the type asks for is assignable — that is ordinary structural subtyping. The exception is a fresh object literal assigned straight to a typed target, which gets an extra check.
interface Point { int x(); int y(); } record LabelledPoint(int x, int y, String label) implements Point {} class Main { public static void main(String[] args) { Point point = new LabelledPoint(1, 2, "origin"); System.out.println(point.x() + point.y()); // point.label() — not visible through the Point type } }
interface Point { x: number; y: number; } const labelled = { x: 1, y: 2, label: "origin" }; const point: Point = labelled; // fine — it has at least the right shape console.log(point.x + point.y); // const direct: Point = { x: 1, y: 2, label: "origin" }; // error: object literal may only specify known properties
The rule exists because a property nobody asked for, written inline, is nearly always a typo or a misremembered option name — the one case where structural typing would happily swallow a mistake. Assigning through a variable opts out of the check, which is also the usual way people work around it.
interface and type
Both name a type. interface describes an object shape and can be reopened; type is an alias for any type at all — a union, a tuple, a function type, a computed type. Java has no equivalent of the second.
interface Shape { double area(); } record Square(double side) implements Shape { public double area() { return side * side; } } class Main { public static void main(String[] args) { Shape shape = new Square(3); System.out.println(shape.area()); } }
interface Shape { area(): number; } type Identifier = string | number; // no Java counterpart type AreaFunction = (shape: Shape) => number; const square: Shape = { area: () => 3 * 3 }; const key: Identifier = "sq-1"; const measure: AreaFunction = (shape) => shape.area(); console.log(measure(square), key);
For plain object shapes the two are interchangeable, and teams pick one by convention. The functional difference is that an interface declared twice in the same scope merges its members, which is how ambient type definitions add to types they do not own — and is also a reason to prefer type when you want the name closed.
Optional and readonly members
port?: number says the property may be absent, which is a different statement from present-and-null. readonly forbids assignment through that type, and is erased.
class Configuration { final String host; final Integer port; Configuration(String host, Integer port) { this.host = host; this.port = port; } } class Main { public static void main(String[] args) { Configuration configuration = new Configuration("localhost", null); int port = configuration.port == null ? 8080 : configuration.port; System.out.println(configuration.host + " " + port); // configuration.host = "other" — rejected: final } }
interface Configuration { readonly host: string; port?: number; } const configuration: Configuration = { host: "localhost" }; console.log(configuration.host, configuration.port ?? 8080); // configuration.host = "other"; // compile error: host is readonly
Java can only express the second half of "absent or null" — a field is always there and may hold null. The distinction matters as soon as data is serialized, because a missing JSON key and a null JSON value are different documents, and TypeScript can describe both.
Index signatures
An index signature says "any string key maps to this type", and it can sit alongside named properties in the same interface. It is the type of an object being used as a dictionary.
import java.util.LinkedHashMap; import java.util.Map; class Main { public static void main(String[] args) { Map<String, String> headers = new LinkedHashMap<>(); headers.put("content-type", "application/json"); headers.put("accept", "*/*"); headers.forEach((headerName, headerValue) -> System.out.println(headerName + ": " + headerValue)); } }
interface Headers { [headerName: string]: string; } const headers: Headers = { "content-type": "application/json" }; headers["accept"] = "*/*"; for (const [headerName, headerValue] of Object.entries(headers)) { console.log(`${headerName}: ${headerValue}`); }
Java has to choose: a class with fixed members, or a Map with none. An index signature is the hybrid — interface Response { status: number; [header: string]: string | number } declares one known property and leaves the rest open, with both halves checked.
Getting nominal typing back
Because shape is identity, type UserId = string protects nothing — every string is a UserId. The workaround is a brand: intersect the type with a property that never actually exists.
record UserId(String value) {} class Main { static String load(UserId id) { return "loading " + id.value(); } public static void main(String[] args) { System.out.println(load(new UserId("u-1"))); // load("u-1") — will not compile: a String is not a UserId } }
type UserId = string & { readonly __brand: "UserId" }; function makeUserId(raw: string): UserId { return raw as UserId; } function load(id: UserId): string { return `loading ${id}`; } console.log(load(makeUserId("u-1"))); // load("u-1"); // compile error: string is not assignable to UserId
Java gets this free — a wrapper type is a distinct type because of its name, and it costs an allocation. The branded version costs nothing at runtime, since the brand is erased and the value stays a plain string; what it costs instead is the as cast in makeUserId, which is the one unchecked hole the whole scheme depends on.
Classes
Classes, and two kinds of private
Classes look familiar, but private is a compile-time marker that vanishes. A field named with a leading # is genuinely private at runtime — a different mechanism with a different spelling.
class Counter { private int count; Counter(int start) { this.count = start; } int increment() { return ++this.count; } } class Main { public static void main(String[] args) { Counter counter = new Counter(10); System.out.println(counter.increment()); } }
class Counter { #count: number; constructor(start: number) { this.#count = start; } increment(): number { return ++this.#count; } } const counter = new Counter(10); console.log(counter.increment());
Choose # when the privacy has to hold against JavaScript callers and against anything reflective, and private when you only want the compiler to enforce a convention. A private field is plainly visible as counter["count"] at runtime; a # field is a syntax error outside the class body.
Parameter properties — the nearest thing to a record
Putting an access modifier on a constructor parameter declares the field, accepts it, and assigns it, all in one place. It is the shortest class TypeScript can write.
record Point(int x, int y) { public String toString() { return "(" + 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); System.out.println(first.equals(second)); } }
class Point { constructor(readonly x: number, readonly y: number) {} toString(): string { return `(${this.x}, ${this.y})`; } } const first = new Point(1, 2); const second = new Point(1, 2); console.log(first.toString()); console.log(first === second); console.log(JSON.stringify(first) === JSON.stringify(second));
What a record gives you that this does not is equals and hashCode. There is no value equality anywhere in the language: two identically-built objects are never ===, they hash to nothing, and a Set or Map keyed by them treats them as distinct. Comparing by content means writing the comparison, and the JSON.stringify trick above is the crude version of it.
Getters and setters are properties
A get or set method is read and written with property syntax — no parentheses at the call site. The JavaBeans naming convention has no role.
class Temperature { private double celsius = 0; double getFahrenheit() { return celsius * 9 / 5 + 32; } void setFahrenheit(double value) { this.celsius = (value - 32) * 5 / 9; } } class Main { public static void main(String[] args) { Temperature temperature = new Temperature(); temperature.setFahrenheit(212); System.out.println(temperature.getFahrenheit()); } }
class Temperature { #celsius = 0; get fahrenheit(): number { return this.#celsius * 9 / 5 + 32; } set fahrenheit(value: number) { this.#celsius = (value - 32) * 5 / 9; } } const temperature = new Temperature(); temperature.fahrenheit = 212; console.log(temperature.fahrenheit);
Because the call is invisible, a plain field can later become an accessor without touching any caller — the reason "always write a getter" never became a rule here. The flip side is that an innocent-looking assignment may run arbitrary code.
Abstract classes and inheritance
extends, super and abstract all behave as expected. The catch is that abstract is compile-time only, so the class that comes out the other side is an ordinary, instantiable one.
abstract class Shape { abstract double area(); String describe() { return getClass().getSimpleName() + " area " + "%.4f".formatted(area()); } } class Circle extends Shape { private final double radius; Circle(double radius) { this.radius = radius; } double area() { return Math.PI * radius * radius; } } class Main { public static void main(String[] args) { System.out.println(new Circle(1).describe()); } }
abstract class Shape { abstract area(): number; describe(): string { return `${this.constructor.name} area ${this.area().toFixed(4)}`; } } class Circle extends Shape { constructor(private readonly radius: number) { super(); } area(): number { return Math.PI * this.radius ** 2; } } console.log(new Circle(1).describe());
this.constructor.name is as close as you get to getClass().getSimpleName(), and it is a plain string with no class object behind it — there is no Class, no getDeclaredFields, and no reflection of any kind. A minifier will happily rename the class and change that string.
this is decided by the call site
A method is a property that happens to hold a function, and this is whatever it was called on. Detach it from its object — hand it to a callback — and the receiver is gone.
import java.util.function.Supplier; class Greeter { private final String name; Greeter(String name) { this.name = name; } String greet() { return "Hello, " + name; } } class Main { public static void main(String[] args) { Greeter greeter = new Greeter("Ada"); Supplier<String> reference = greeter::greet; System.out.println(reference.get()); } }
class Greeter { constructor(private readonly name: string) {} greet(): string { return `Hello, ${this.name}`; } } const greeter = new Greeter("Ada"); const detached = greeter.greet; try { console.log(detached()); } catch (error) { console.log("detached call lost its receiver"); } const bound = greeter.greet.bind(greeter); console.log(bound());
A Java method reference captures the receiver permanently, so greeter::greet can be passed anywhere. The three fixes here are bind, an arrow function that closes over the object, or declaring the member as an arrow-function field — and forgetting all three is the classic bug when a method is used as an event handler.
Unions & Narrowing
Union types
A union type is written with | and means "one of these". The members need no common supertype, no wrapper, and no idea that they have been put in a union together.
sealed interface Identifier permits NumberIdentifier, TextIdentifier {} record NumberIdentifier(int value) implements Identifier {} record TextIdentifier(String value) implements Identifier {} class Main { static String describe(Identifier identifier) { return switch (identifier) { case NumberIdentifier(int value) -> "#" + value; case TextIdentifier(String value) -> value.toUpperCase(); }; } public static void main(String[] args) { System.out.println(describe(new NumberIdentifier(7))); System.out.println(describe(new TextIdentifier("ada"))); } }
type Identifier = number | string; function describe(identifier: Identifier): string { return typeof identifier === "number" ? `#${identifier}` : identifier.toUpperCase(); } console.log(describe(7)); console.log(describe("ada"));
Java can express this only by inventing a hierarchy: an interface, a permits clause, and a record per case, all of which must be written before the union can exist. A TypeScript union is ad hoc — number | string can be written at the point of use, and number and string are untouched by it.
Literal unions instead of enums
A union of string literals is the everyday stand-in for an enum. The values are plain strings at runtime, and the set of allowed ones exists only at compile time.
enum Method { GET, POST, DELETE } class Main { static String send(Method method, String path) { return method + " " + path; } public static void main(String[] args) { System.out.println(send(Method.GET, "/users")); // send("PATCH", "/users") — will not compile: not a Method } }
type Method = "GET" | "POST" | "DELETE"; function send(method: Method, path: string): string { return `${method} ${path}`; } console.log(send("GET", "/users")); // send("PATCH", "/users"); // compile error: not one of the three
Because the value is just a string, it serializes and deserializes with no mapping layer — and it also arrives from the network entirely unchecked, so a validator at the boundary is doing work the type system cannot. TypeScript does have an enum keyword, but it emits a runtime object and behaves unlike everything around it, which is why literal unions are preferred.
Discriminated unions
Give every member of a union a shared literal-typed property, and testing that property narrows the value to exactly one member. It is the sealed-interface-and-record-pattern shape, without the declarations.
sealed interface Result permits Ok, Failure {} record Ok(int value) implements Result {} record Failure(String message) implements Result {} class Main { static String render(Result result) { return switch (result) { case Ok(int value) -> "ok " + value; case Failure(String message) -> "error " + message; }; } public static void main(String[] args) { System.out.println(render(new Ok(42))); System.out.println(render(new Failure("nope"))); } }
type Result = | { kind: "ok"; value: number } | { kind: "error"; message: string }; function render(result: Result): string { switch (result.kind) { case "ok": return `ok ${result.value}`; case "error": return `error ${result.message}`; } } console.log(render({ kind: "ok", value: 42 })); console.log(render({ kind: "error", message: "nope" }));
Inside case "ok" the compiler knows result has a value and no message, so reading the wrong one is a compile error — the same guarantee case Ok(int value) gives. The discriminant is an ordinary property, which means it survives JSON.stringify and comes back intact.
Exhaustiveness with never
never is the empty type — no value has it. Assigning the narrowed value to a never in the default branch asserts that every case has been handled, because anything left over would fail that assignment.
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 circle -> Math.PI * circle.radius() * circle.radius(); case Square square -> square.side() * square.side(); }; } public static void main(String[] args) { System.out.println(area(new Square(3))); } }
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; default: { const unreachable: never = shape; throw new Error(`unhandled shape ${JSON.stringify(unreachable)}`); } } } console.log(area({ kind: "square", side: 3 }));
A sealed interface gives Java this for free: a switch with no default must cover every permitted type, and adding a new one breaks the build. TypeScript needs the idiom written out, but the effect is the same — add a third member to the union and the never assignment stops compiling, naming the case you forgot.
User-defined type guards
There is no instanceof for an interface, because the interface is not there at runtime. Instead a function returns a boolean and declares what a true answer proves, with a value is Person return type.
record Person(String name) {} class Main { static String describe(Object value) { if (value instanceof Person person) { return person.name().toUpperCase(); } return "not a person"; } public static void main(String[] args) { System.out.println(describe(new Person("Ada"))); System.out.println(describe("Ada")); } }
interface Person { name: string; } function isPerson(value: unknown): value is Person { return typeof value === "object" && value !== null && typeof (value as Person).name === "string"; } function describe(value: unknown): string { return isPerson(value) ? value.name.toUpperCase() : "not a person"; } console.log(describe(JSON.parse('{"name":"Ada"}'))); console.log(describe("Ada"));
Nothing checks that the body of a guard is honest — the compiler simply believes the signature, and a guard that returns true for the wrong values silently poisons everything downstream. That unverified promise is the price of erasure, and it is why validation libraries that generate both the checker and the type from one schema are so widely used.
Null & Undefined
There are two kinds of nothing
undefined means "never given a value" and is what you get from a missing property or an omitted argument. null means "deliberately empty" and only appears where someone wrote it.
class Main { public static void main(String[] args) { String missing = null; System.out.println(missing); System.out.println(missing == null); } }
let missing: string | undefined; const absent: string | null = null; console.log(missing); console.log(absent); console.log(missing == null); // true — the loose check catches both console.log(missing === null); // false
Loose equality is worth avoiding everywhere else, but == null is the idiomatic exception: it is true for exactly null and undefined and nothing else. Most code avoids producing null at all and treats undefined as the single absence, which is the closest you can get to Java's one-hole model.
Nullability is part of the type
Under strictNullChecks — on by default in any modern configuration — a string genuinely cannot be null. If a value might be absent, its type says so, and the compiler refuses to dereference it until you have checked.
class Main { static String firstWord(String text) { if (text == null) { return ""; } return text.split(" ")[0]; } public static void main(String[] args) { System.out.println(firstWord("hello world")); System.out.println("[" + firstWord(null) + "]"); } }
function firstWord(text: string | undefined): string { // text.split(" ") // compile error: text is possibly undefined if (text === undefined) { return ""; } return text.split(" ")[0]; } console.log(firstWord("hello world")); console.log(`[${firstWord(undefined)}]`);
This is the one place TypeScript is meaningfully stricter than Java. Every Java reference is implicitly nullable and nothing forces a check, so a NullPointerException is always reachable; here the possibility has to be written into the signature, and once it is, the compiler will not let you ignore it.
Optional chaining and nullish coalescing
?. short-circuits the whole chain to undefined at the first absent link, and ?? supplies a fallback for null or undefined only. Both are syntax — nothing is wrapped and nothing needs unwrapping.
import java.util.Optional; record Address(String city) {} record Person(Address address) {} class Main { public static void main(String[] args) { Person nowhere = new Person(null); System.out.println(Optional.ofNullable(nowhere.address()) .map(Address::city).orElse("unknown")); Person somewhere = new Person(new Address("London")); System.out.println(Optional.ofNullable(somewhere.address()) .map(Address::city).orElse("unknown")); } }
interface Address { city?: string } interface Person { address?: Address } const nowhere: Person = {}; console.log(nowhere.address?.city ?? "unknown"); const somewhere: Person = { address: { city: "London" } }; console.log(somewhere.address?.city ?? "unknown");
Note that ?? is not ||: the older operator falls back on any falsy value, so count || 10 quietly replaces a legitimate 0. Use ?? whenever the fallback is meant for absence rather than for emptiness.
The non-null assertion, and what it costs
A trailing ! tells the compiler a value is not null or undefined. Nothing verifies it, and nothing remains of it at runtime — it is erased along with every other type.
import java.util.Map; class Main { public static void main(String[] args) { Map<String, Integer> values = Map.of("a", 1); System.out.println(values.get("a") + 1); try { System.out.println(values.get("b") + 1); } catch (NullPointerException error) { System.out.println("NullPointerException"); } } }
const values = new Map<string, number>([["a", 1]]); const present = values.get("a"); // number | undefined console.log(present! + 1); // trusted, unchecked const missing = values.get("b"); console.log(missing! + 1); // NaN — no error at all console.log((missing ?? 0) + 1); // the honest version
Java's implicit unboxing is the same kind of promise, and the difference is what happens when it is wrong: the JVM throws at the point of the mistake, while TypeScript produces NaN or undefined and carries it onward until something much later fails for a reason that no longer points at the cause.
Generics & Erasure
Generic functions
The type parameter list goes after the function name, exactly where Java puts it on a method — the difference is only that a TypeScript function does not need a class to live in.
import java.util.List; 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(firstOr(List.<Integer>of(), 0)); } }
function firstOr<T>(items: T[], fallback: T): T { return items.length === 0 ? fallback : items[0]; } console.log(firstOr(["a", "b"], "z")); console.log(firstOr<number>([], 0));
Inference works the same way and fails in the same place — an empty array carries no evidence of its element type, so the call is written with an explicit <number> just as the Java call needs List.<Integer>of().
Constraints are shapes
A bound is written T extends ... just as in Java, but what follows is a shape rather than a supertype. Anything with the right members satisfies it, whether or not it was designed to.
import java.util.Collection; import java.util.List; class Main { static <T extends Collection<?>> T longest(List<T> items) { T best = items.get(0); for (T item : items) { if (item.size() > best.size()) { best = item; } } return best; } public static void main(String[] args) { System.out.println(longest(List.of(List.of(1), List.of(1, 2, 3)))); // longest(List.of("aa", "bbbb")) — rejected: String is not a Collection } }
function longest<T extends { length: number }>(items: T[]): T { let best = items[0]; for (const item of items) { if (item.length > best.length) { best = item; } } return best; } console.log(longest(["aa", "bbbb", "c"])); console.log(longest([[1], [1, 2, 3]]).join(","));
Strings and arrays both satisfy { length: number } without either of them implementing anything, which is why one function handles both calls. The Java column cannot be written to accept both: its bound has to name a supertype, and there is no interface that String and List both implement — so the list case is all it gets.
Both languages erase — but not equally
Java erases type arguments and keeps a class object for every class. TypeScript erases the entire type system, so there is no class object for an interface and nothing at all to inspect.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<String> words = new ArrayList<>(List.of("a")); System.out.println(words.getClass().getSimpleName()); System.out.println(words.get(0) instanceof String); // words instanceof List<String> — will not compile: erased } }
function describeType<T>(value: T): string { // there is no `T` here to ask about — it does not exist at runtime return typeof value; } console.log(describeType<string>("a")); console.log(describeType<number>(1)); console.log(Array.isArray(["a"]));
Neither language can write new T(), so both reach for a factory function passed in by the caller. What Java still has is getClass(), instanceof against a real class, and a Class<T> token you can hand around; TypeScript has typeof, Array.isArray, and instanceof against classes only — nothing that can see an interface or a type alias.
No wildcards, and arrays are unsound
There is no ? extends or ? super, because there is nothing for them to fix: an array of a subtype is simply assignable to an array of the supertype. That convenience is not type-safe, and TypeScript accepts it anyway.
import java.util.ArrayList; import java.util.List; interface Animal { String name(); } record Dog(String name) implements Animal {} class Main { static int count(List<? extends Animal> animals) { return animals.size(); } public static void main(String[] args) { List<Dog> dogs = new ArrayList<>(List.of(new Dog("Rex"))); // List<Animal> animals = dogs; — rejected: generics are invariant System.out.println(count(dogs)); } }
interface Animal { name: string } interface Dog extends Animal { bark(): string } const dogs: Dog[] = [{ name: "Rex", bark: () => "woof" }]; const animals: Animal[] = dogs; // allowed: arrays are covariant animals.push({ name: "Whiskers" }); // type-checks, and corrupts dogs console.log(dogs.length); console.log(typeof dogs[1].bark); // "undefined" — a Dog that cannot bark
The soundness Java buys with invariance and wildcards is simply not bought here — this is a documented, deliberate unsoundness, traded for not having to write ? extends on every reading parameter. The closest thing to a producer-only declaration is readonly Animal[], which at least removes push from the type.
Generic classes
A class takes type parameters the same way, and a method can introduce its own on top. Inference at the call site means new Box(21) needs no explicit argument.
import java.util.function.Function; class Box<T> { private final T value; Box(T value) { this.value = value; } T get() { return value; } <R> Box<R> map(Function<T, R> mapper) { return new Box<>(mapper.apply(value)); } } class Main { public static void main(String[] args) { System.out.println(new Box<>(21).map(value -> value * 2).get()); } }
class Box<T> { constructor(private readonly value: T) {} get(): T { return this.value; } map<R>(mapper: (value: T) => R): Box<R> { return new Box(mapper(this.value)); } } console.log(new Box(21).map((value) => value * 2).get());
Because the type parameter is erased, a Box<string> and a Box<number> are the same object at runtime in both languages — but only Java can still ask the box what class it is. Note also that new Box(...) infers T, so the diamond has nothing left to elide.
Type-Level Programming
Deriving one type from another
Partial, Pick, Omit and Readonly build a new type out of an existing one. The derived shape cannot drift, because it is recomputed from the original every time the code is checked.
record User(int id, String name, String email) {} record UserPatch(String name) {} record UserSummary(int id, String name) {} class Main { public static void main(String[] args) { UserPatch patch = new UserPatch("Ada"); UserSummary summary = new UserSummary(1, "Ada"); System.out.println(patch.name() + " " + summary.id() + " " + summary.name()); } }
interface User { id: number; name: string; email: string; } type UserPatch = Partial<User>; type UserSummary = Pick<User, "id" | "name">; type UserWithoutEmail = Omit<User, "email">; const patch: UserPatch = { name: "Ada" }; const summary: UserSummary = { id: 1, name: "Ada" }; console.log(patch.name, summary.id, summary.name);
Java has no way to compute a type from another type, so every projection of User is a separate record kept in step by hand — and adding a field to User silently leaves them behind. Rename name in the TypeScript version and all three derived types stop compiling until they are updated.
keyof and indexed access
keyof T is the union of T's property names, and T[K] looks the corresponding value type back up. Together they let one signature give a different return type per key.
import java.util.LinkedHashMap; import java.util.Map; class Main { public static void main(String[] args) { Map<String, Object> defaults = new LinkedHashMap<>(); defaults.put("host", "localhost"); defaults.put("port", 8080); int port = (Integer) defaults.get("port"); String host = (String) defaults.get("host"); System.out.println(host + " " + port); } }
const defaults = { host: "localhost", port: 8080, secure: false }; type Settings = typeof defaults; type SettingName = keyof Settings; // "host" | "port" | "secure" function read<K extends SettingName>(name: K): Settings[K] { return defaults[name]; } const port: number = read("port"); const host: string = read("host"); console.log(host, port);
Note typeof defaults in type position: it lifts a value into the type world, so the settings object is declared once and its type is derived rather than duplicated. The Java version has to widen everything to Object and cast on the way out, and a mistyped key is a runtime null instead of a compile error.
Mapped and conditional types
A mapped type rewrites every property of another type; a conditional type pattern-matches a type and can pull a piece out of it with infer. This is where the type system stops describing and starts computing.
record User(int id, String name) {} record NullableUser(Integer id, String name) {} class Main { public static void main(String[] args) { NullableUser draft = new NullableUser(1, null); System.out.println(draft.id() + " " + draft.name()); } }
interface User { id: number; name: string; } type Nullable<T> = { [Key in keyof T]: T[Key] | null }; type ElementOf<T> = T extends readonly (infer Element)[] ? Element : never; const draft: Nullable<User> = { id: 1, name: null }; const word: ElementOf<string[]> = "a"; console.log(JSON.stringify(draft), word);
This is the one corner of TypeScript with no Java counterpart at any level — not generics, not annotations, not reflection. Nullable<User> is written once and applies to every type you ever pass it, where the Java column must hand-write a parallel record for each shape and keep it in sync forever.
as const — one declaration, two lives
as const stops the compiler widening a literal: the array below becomes a readonly ["GET", "POST"] tuple instead of a string[]. Indexing that tuple type by number reads the union of its elements back out.
import java.util.Arrays; enum Method { GET, POST } class Main { static String send(Method method) { return method + " /users"; } public static void main(String[] args) { System.out.println(send(Method.GET)); System.out.println(Arrays.toString(Method.values())); } }
const methods = ["GET", "POST"] as const; type Method = typeof methods[number]; // "GET" | "POST" function send(method: Method): string { return `${method} /users`; } console.log(send("GET")); console.log(methods.join(","));
A Java enum also gives you the type and values() from one declaration, and gives you more besides — constructors, fields, methods, and a real identity. What it cannot be is a plain string on the wire, which is why the as const pattern wins wherever the values cross a network or a database boundary.
Functional Operations
map, filter and reduce
These live on the array itself. There is no stream() to open the pipeline and no collect to close it — each call takes an array and gives back an array.
import java.util.List; 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); } }
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);
The price of the shorter spelling is that these are eager: each stage allocates a complete intermediate array, so a five-stage chain over a million elements builds five arrays where a Java stream builds none. For large data the answer is a generator, not a longer chain.
Grouping
Object.groupBy is the counterpart of Collectors.groupingBy. It returns a plain object whose keys are the grouping values converted to strings.
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); } }
const words = ["ant", "bee", "ape", "bat"]; const grouped = Object.groupBy(words, (word) => word[0]); console.log(JSON.stringify(grouped));
There is no downstream collector to compose, so counting or summing per group means a second pass over the grouped object rather than a Collectors.counting() argument. Keys are always strings, so grouping by a number gives you "1" rather than 1.
Flattening, and sorting without mutating
flat() is flatMap(List::stream) for the common case of nested arrays. toSorted is the copying twin of sort — a family that also includes toReversed, toSpliced and with.
import java.util.Comparator; import java.util.List; class Main { public static void main(String[] args) { List<List<String>> nested = List.of(List.of("b", "a"), List.of("d", "c")); List<String> flattened = nested.stream() .flatMap(List::stream) .sorted(Comparator.naturalOrder()) .toList(); System.out.println(String.join(",", flattened)); } }
const nested = [["b", "a"], ["d", "c"]]; const flattened = nested .flat() .toSorted((left, right) => left.localeCompare(right)); console.log(flattened.join(",")); console.log(nested.flat().join(",")); // the original order is untouched
flat() descends one level by default and takes a depth argument — flat(Infinity) for all of them. Reaching for toSorted rather than sort is worth making a habit, because sort reorders the array you were handed, which is rarely what a caller expects.
Copy-with-changes
Spreading an object into a new literal copies all of its own properties, and any property written afterwards wins. It is the record with-expression Java does not have yet.
import java.util.ArrayList; import java.util.List; record User(int id, String name, List<String> tags) {} class Main { public static void main(String[] args) { User user = new User(1, "Ada", List.of("author")); User renamed = new User(user.id(), "Ada L.", user.tags()); List<String> moreTags = new ArrayList<>(user.tags()); moreTags.add("pioneer"); User tagged = new User(user.id(), user.name(), List.copyOf(moreTags)); System.out.println(renamed.name() + " " + String.join(",", tagged.tags())); } }
interface User { id: number; name: string; tags: string[]; } const user: User = { id: 1, name: "Ada", tags: ["author"] }; const renamed: User = { ...user, name: "Ada L." }; const tagged: User = { ...user, tags: [...user.tags, "pioneer"] }; console.log(renamed.name, tagged.tags.join(","));
The Java column has to name every component it is not changing, which is exactly the code that goes stale when a component is added. Spread is a shallow copy in both directions: renamed.tags is the same array object as user.tags, just as a record's component reference is shared.
Generators are the lazy sequence
A function* produces values one at a time with yield and suspends in between, so an infinite sequence is written as an ordinary infinite loop. This is where Stream's laziness went.
import java.util.stream.Collectors; import java.util.stream.Stream; class Main { public static void main(String[] args) { String firstFive = Stream.iterate(1, value -> value + 1) .map(value -> value * value) .limit(5) .map(String::valueOf) .collect(Collectors.joining(",")); System.out.println(firstFive); } }
function* squares(): Generator<number> { for (let value = 1; ; value += 1) { yield value * value; } } const firstFive: number[] = []; for (const square of squares()) { if (firstFive.length === 5) { break; } firstFive.push(square); } console.log(firstFive.join(","));
A generator is iterable, so it works with for...of, spread, and destructuring, and it can be consumed only once — the same one-shot rule a Stream has. What it lacks is the operator vocabulary: there is no built-in limit or lazy map for generators, so those are written by hand or taken from a library.
Error Handling
try, catch, finally
The three keywords behave as they do in Java, with one structural difference: there is exactly one catch clause and it cannot be typed. Its binding is unknown, because a throw can carry any value at all.
class Main { public static void main(String[] args) { try { throw new IllegalStateException("boom"); } catch (IllegalStateException error) { System.out.println("caught " + error.getMessage()); } finally { System.out.println("always"); } } }
try { throw new Error("boom"); } catch (error) { console.log("caught", error instanceof Error ? error.message : String(error)); } finally { console.log("always"); }
Selecting on the exception type — what catch (IllegalStateException error) does for you — becomes an instanceof test inside the one clause, followed by a rethrow of anything you did not mean to handle. Forgetting that rethrow is how a catch quietly swallows unrelated failures.
Nothing is checked
No signature mentions what a function can throw, and the compiler never requires a handler. Every failure behaves like a Java unchecked exception.
import java.io.IOException; class Main { static String read() throws IOException { throw new IOException("disk unavailable"); } public static void main(String[] args) { try { System.out.println(read()); } catch (IOException error) { System.out.println("handled " + error.getMessage()); } } }
function read(): string { throw new Error("disk unavailable"); } try { console.log(read()); } catch (error) { console.log("handled", (error as Error).message); }
A TypeScript signature tells you what a function returns when it succeeds and nothing whatsoever about how it fails, so the only way to know is to read the body — or the documentation, or the incident. That gap is the reason result-shaped return types (the next-but-one row) keep being reinvented.
Custom error classes
Extending Error gives you a real class with a real prototype, which is one of the few places a TypeScript type survives to runtime — and therefore the only reliable way to tell your failures apart.
class ValidationException extends RuntimeException { private final String field; ValidationException(String field, String message) { super(message); this.field = field; } String field() { return field; } } class Main { public static void main(String[] args) { try { throw new ValidationException("email", "is required"); } catch (ValidationException error) { System.out.println(error.field() + " " + error.getMessage()); } } }
class ValidationError extends Error { constructor(readonly field: string, message: string) { super(message); this.name = "ValidationError"; } } try { throw new ValidationError("email", "is required"); } catch (error) { if (error instanceof ValidationError) { console.log(error.field, error.message); } }
Setting this.name is worth the extra line: it is what appears in a stack trace and in a logged error, and without it every subclass prints as Error. This is also why error hierarchies are built from classes rather than from interfaces or unions — an interface would leave nothing for instanceof to test.
Returning the failure instead of throwing
Because a thrown value is invisible to the type system, failures that callers are expected to handle are often returned instead — as a discriminated union that the compiler forces them to open.
sealed interface ParseResult permits Success, Failure {} record Success(int value) implements ParseResult {} record Failure(String error) implements ParseResult {} class Main { static ParseResult parseCount(String text) { try { return new Success(Integer.parseInt(text)); } catch (NumberFormatException error) { return new Failure("not an integer: " + text); } } public static void main(String[] args) { for (String input : new String[] { "12", "x" }) { System.out.println(switch (parseCount(input)) { case Success(int value) -> "value " + value; case Failure(String error) -> error; }); } } }
type ParseResult = | { ok: true; value: number } | { ok: false; error: string }; function parseCount(text: string): ParseResult { const parsed = Number(text); return Number.isInteger(parsed) ? { ok: true, value: parsed } : { ok: false, error: `not an integer: ${text}` }; } for (const input of ["12", "x"]) { const result = parseCount(input); console.log(result.ok ? `value ${result.value}` : result.error); }
The shapes are the same and so is the guarantee — reading result.value without testing result.ok does not compile — but the TypeScript version needs no interface, no permits clause and no record per case. Note also that Number("x") yields NaN rather than throwing, which is why the check is Number.isInteger and not a try.
Async & the Event Loop
async and await
An async function always returns a Promise, and await unwraps one. A Promise<T> is the CompletableFuture<T> of this world — with the difference that awaiting it never occupies a thread, because there is only one.
import java.util.concurrent.CompletableFuture; class Main { static CompletableFuture<Integer> compute(int value) { return CompletableFuture.supplyAsync(() -> value * 2); } public static void main(String[] args) { int result = compute(21).join(); System.out.println(result); } }
async function compute(value: number): Promise<number> { return value * 2; } const result = await compute(21); console.log(result);
await is written at the top level of a file here, which is legal because a module body is itself asynchronous — there is no main to make async and no join() to block on. Blocking, in fact, is not available at all: there is no equivalent of Future.get() that stops and waits.
Waiting for several at once
Promise.all takes an array of promises and gives back a promise of an array, in the same order. It is CompletableFuture.allOf with the results already collected for you.
import java.util.List; import java.util.concurrent.CompletableFuture; class Main { static CompletableFuture<Integer> compute(int value) { return CompletableFuture.supplyAsync(() -> value * value); } public static void main(String[] args) { List<CompletableFuture<Integer>> pending = List.of(compute(3), compute(4)); CompletableFuture.allOf(pending.toArray(new CompletableFuture[0])).join(); int total = pending.stream().mapToInt(CompletableFuture::join).sum(); System.out.println(total); } }
async function compute(value: number): Promise<number> { return value * value; } const results = await Promise.all([compute(3), compute(4)]); console.log(results.reduce((sum, value) => sum + value, 0));
The tuple type is preserved, so awaiting [compute(3), someText()] gives a [number, string] rather than an array of a union. Note what "at once" means here: the two calls make progress by interleaving on one thread, so this overlaps waiting, never computation.
One thread, so no synchronization
Nothing runs in parallel. Two asynchronous functions touching the same variable cannot interleave mid-statement, so there is no race to guard against and no synchronized, volatile or AtomicInteger to reach for.
import java.util.concurrent.atomic.AtomicInteger; class Main { public static void main(String[] args) throws InterruptedException { AtomicInteger counter = new AtomicInteger(); Runnable work = () -> { for (int index = 0; index < 50_000; index++) { counter.incrementAndGet(); } }; Thread first = new Thread(work); Thread second = new Thread(work); first.start(); second.start(); first.join(); second.join(); System.out.println(counter.get()); } }
let counter = 0; async function increment(times: number): Promise<void> { for (let index = 0; index < times; index += 1) { counter += 1; } } await Promise.all([increment(50_000), increment(50_000)]); console.log(counter);
Replace the AtomicInteger with a plain int in the Java column and the answer stops being 100000; the TypeScript column has no such version to get wrong. The price is that nothing gets faster on more cores — real parallelism needs Web Workers or a worker thread, which communicate by copying messages rather than by sharing memory. This example cannot be run in the browser: the execution environment has limited thread support. Run it anywhere else and it prints 100000.
There is no sleep
Nothing blocks the single thread, so Thread.sleep has no counterpart. Waiting means creating a promise that a timer resolves and awaiting that.
class Main { public static void main(String[] args) throws InterruptedException { long started = System.nanoTime(); Thread.sleep(50); long elapsedMilliseconds = (System.nanoTime() - started) / 1_000_000; System.out.println(elapsedMilliseconds >= 50 ? "waited" : "returned early"); } }
function delay(milliseconds: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } const started = Date.now(); await delay(50); console.log(Date.now() - started >= 50 ? "waited" : "returned early");
The delay helper above is written out in nearly every codebase, because the standard library does not ship one. The upside of having no blocking sleep is that a waiting task costs nothing — there is no thread parked on it, which is the same benefit Java only recently got from virtual threads.
Modules & Data
Every file is a module
There is no package statement and no classpath. A file that contains an export is a module, and an import names the file by path — so the directory layout is the namespace.
// File: com/example/Greeting.java package com.example; public class Greeting { public static String of(String name) { return "Hello, " + name; } } // File: Main.java import com.example.Greeting; class Main { public static void main(String[] args) { System.out.println(Greeting.of("Ada")); } }
// File: greeting.ts export function greeting(name: string): string { return `Hello, ${name}`; } // File: main.ts import { greeting } from "./greeting.js"; console.log(greeting("Ada"));
Anything not exported is private to its file, which is the real unit of encapsulation — there is nothing like package-private visibility across several files. The .js in the import path is not a typo: the specifier names the file that will exist after compilation, which trips up nearly everyone once.
JSON is built in, and it hands you any
Serializing and parsing need no library. What they also need is no type — JSON.parse returns any, so everything the compiler knew about the data stops at that call.
record Person(String name, int born) {} class Main { public static void main(String[] args) { // The standard library ships no JSON parser; a library such as Jackson // maps a document onto a record and checks the shape as it builds it. Person person = new Person("Ada", 1815); String document = "{\"name\":\"" + person.name() + "\",\"born\":" + person.born() + "}"; System.out.println(document); } }
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
The misspelling above compiles because any permits everything, which is exactly why every network and storage boundary wants a validator that checks the document and returns a properly typed value. Java has the opposite problem: no JSON at all in the standard library, so a dependency is mandatory — but that dependency does the checking.
Copying deeply
Spread and Object.assign copy one level, exactly as a copy constructor does. structuredClone is the built-in deep copy, and unlike Java's serialization trick it needs nothing implemented on the values.
import java.util.ArrayList; import java.util.List; class Main { public static void main(String[] args) { List<List<String>> original = new ArrayList<>(); original.add(new ArrayList<>(List.of("a"))); List<List<String>> shallow = new ArrayList<>(original); shallow.get(0).add("b"); System.out.println(original.get(0).size()); List<List<String>> deep = new ArrayList<>(); for (List<String> inner : original) { deep.add(new ArrayList<>(inner)); } deep.get(0).add("c"); System.out.println(original.get(0).size()); } }
const original = [["a"]]; const shallow = [...original]; shallow[0].push("b"); console.log(original[0].length); // 2 — the inner array is shared const deep = structuredClone(original); deep[0].push("c"); console.log(original[0].length); // still 2
structuredClone handles cycles, Map, Set, Date and typed arrays, and refuses functions and class prototypes — so a cloned object keeps its data and loses its methods. That last part is the one to remember: cloning a class instance gives you a plain object that no longer answers instanceof.