PONY λ M2 Modula-2

Kotlin.CodeCompared.To/TypeScript

An interactive executable cheatsheet comparing Kotlin and TypeScript

Kotlin 2.3 TypeScript 6.0
Basics & Bindings
Hello, World
fun main() { println("Hello, World!") }
console.log("Hello, World!");
TypeScript is JavaScript with a type layer, so scripts run top-level statements with no entry function. Everything on this page compiles to plain JavaScript with the types deleted — a fact that quietly drives half the differences ahead.
val/var becomes const/let
fun main() { val fixed = 1 var counter = 0 counter += 1 println(fixed + counter) }
const fixed = 1; let counter = 0; counter += 1; console.log(fixed + counter);
The mapping is exact — val/const, var/let — with the same caveat Kotlin has: const pins the binding, not the object, so a const array still mutates (the collections section returns to this). JavaScript's legacy var keyword still parses; treat it as a code smell from another decade.
Inference & annotations
fun main() { val answer = 42 val ratio: Double = 3.14 val greeting = "hello" println("$answer $ratio $greeting") }
const answer = 42; const ratio: number = 3.14; const greeting = "hello"; console.log(`${answer} ${ratio} ${greeting}`);
Annotations sit after a colon, as in Kotlin, and inference handles most bindings. Two number notes: there is one number type (double-precision float — no Int/Long/Double menu, and 0.1 + 0.2 !== 0.3), plus bigint for Kotlin's Long-and-beyond cases. Template literals use backticks with ${…}, nearly Kotlin's syntax.
Erased, Structural, Gradual
Structural typing: shape is identity
The deepest inversion of JVM instincts. Kotlin types are nominal — a class is its declared identity. TypeScript types are structural — anything with the right shape is the type, no declaration needed.
interface Named { val name: String } class Person(override val name: String) : Named fun greet(named: Named) = "Hello, ${named.name}!" fun main() { println(greet(Person("Ada"))) // greet(object : Named { ... }) — must declare conformance }
interface Named { name: string; } function greet(named: Named): string { return `Hello, ${named.name}!`; } const person = { name: "Ada", age: 36 }; console.log(greet(person));
person never mentions Named — it has a name: string, so it qualifies, extra properties and all. This is duck typing checked at compile time (Go's implicit interfaces are the same family). The payoff is frictionless composition with plain object literals; the trap is that two accidentally same-shaped types are interchangeable, which is why the value class-style branded-type tricks exist in TypeScript.
Types do not exist at runtime
Kotlin erases generics but keeps classes at runtime. TypeScript erases everything: interfaces, annotations, generics — the running program is plain JavaScript.
data class User(val name: String) fun main() { val value: Any = User("Ada") if (value is User) { println("runtime check works: ${value.name}") } }
interface User { name: string; } const parsed: User = JSON.parse('{"name": "Ada"}'); console.log(parsed.name); // if (parsed instanceof User) {} ← does not even compile: // User is a type, and types are erased before runtime. console.log(typeof parsed);
There is no is User — an interface has no runtime existence to check against, and JSON.parse returns any that you assert into shape, unverified. The ecosystem's answer is runtime validators (zod and friends) at the boundaries, doing by hand what the JVM's reflective type checks did for free. Inside the typed core, narrowing (next section) replaces is.
any is a legal lie; unknown is the honest one
Kotlin's type system has no off switch. TypeScript ships one: any disables checking for a value — and its disciplined sibling unknown requires proof before use.
fun main() { val value: Any = "hello" // value.length ← does not compile: Any has no length if (value is String) { println(value.length) } }
const dangerous: any = "hello"; console.log(dangerous.size); // Kotlin muscle memory — compiles, prints undefined const honest: unknown = "hello"; // honest.length ← does not compile: unknown must be narrowed if (typeof honest === "string") { console.log(honest.length); }
That .size is exactly the mistake a Kotlin developer types (JavaScript strings have .length) — and under any it compiles silently and prints undefined, because any switches checking off for everything it touches. unknown is what Kotlin's Any actually is: a value you must narrow before touching. The practical rule for JVM refugees: enable strict, lint against any, and treat as casts with the suspicion Kotlin taught you for !!.
Literal types replace enums
Kotlin models a closed set of options with enum class. Idiomatic TypeScript uses a union of string literal types — the values themselves are the types.
enum class Direction { NORTH, SOUTH, EAST, WEST } fun describe(direction: Direction): String = when (direction) { Direction.NORTH -> "up the map" Direction.SOUTH -> "down the map" Direction.EAST -> "to the right" Direction.WEST -> "to the left" } fun main() { println(describe(Direction.NORTH)) }
type Direction = "north" | "south" | "east" | "west"; function describe(direction: Direction): string { switch (direction) { case "north": return "up the map"; case "south": return "down the map"; case "east": return "to the right"; case "west": return "to the left"; } } console.log(describe("north")); // describe("up") ← compile error: not one of the four literals
The type "north" | "south" | … admits exactly four strings, checked at compile time and erased at runtime — no enum object, no imports at call sites, just the string. TypeScript has an enum keyword, but it is the language's most-regretted feature (it generates runtime code, unlike every other type construct) and modern style avoids it in favor of literal unions.
null, undefined & Narrowing
T? becomes T | null — with the same operators
With strictNullChecks on (part of strict), TypeScript tracks nullability in the type like Kotlin — and even shares the ?. and ?? operators.
fun main() { val name: String? = null println(name?.length ?: 0) val nickname: String? = "Ada" println(nickname?.length ?: 0) }
const name: string | null = null; console.log(name?.length ?? 0); const nickname: string | null = "Ada"; console.log(nickname?.length ?? 0);
Kotlin's T? is sugar for what TypeScript writes out: a union with the absent value. ?. is identical; Kotlin's elvis ?: is ?? (do not reach for JavaScript's ||, which also swallows 0 and ""). The ! postfix is !! — same crash, same code-review eyebrow. All of it depends on strict being on; without it you are back in pre-Kotlin null land.
Two absences: null AND undefined
Kotlin has one nothing. JavaScript has two — undefined (never set) and null (deliberately empty) — and TypeScript types them separately.
fun main() { val ages = mapOf("Ada" to 36) val age: Int? = ages["Grace"] // one kind of absent println(age) println(age ?: 0) }
const ages = new Map([["Ada", 36]]); const age = ages.get("Grace"); // undefined, not null console.log(age); let nickname: string | undefined; // declared, never assigned console.log(nickname); const cleared: string | null = null; // deliberately empty console.log(cleared ?? "(none)");
The working convention: the language produces undefined (missing properties, no-value returns, unset variables), while null is something a programmer wrote on purpose. ?. and ?? treat both alike, which usually lets you not care — but API types will say string | undefined or string | null and mean it, and value == null (loose equality, deliberately) is the standard test that catches both.
Smart casts are called narrowing
Kotlin's smart casts have a direct TypeScript twin: control-flow narrowing, driven by typeof, in, equality checks, and custom type-guard functions.
fun describe(value: Any): String = when (value) { is Int -> "an Int of $value" is String -> "a String of length ${value.length}" else -> "something else" } fun main() { println(describe(42)) println(describe("hello")) }
function describe(value: unknown): string { if (typeof value === "number") { return `a number of ${value}`; } if (typeof value === "string") { return `a string of length ${value.length}`; } return "something else"; } console.log(describe(42)); console.log(describe("hello"));
Inside each branch, value is the narrowed type — the same flow analysis as Kotlin's, retyping the same name. The guards differ because types are erased: typeof for primitives, instanceof for classes, "property" in value for shapes, and user-defined predicates (value is Fish return types) where Kotlin would write an is check.
Unions vs Sealed Classes
Ad-hoc unions
Kotlin has no way to say "an Int or a String" without a wrapper hierarchy. TypeScript says it inline: number | string.
sealed interface IdValue data class NumericId(val value: Int) : IdValue data class TextId(val value: String) : IdValue fun format(id: IdValue): String = when (id) { is NumericId -> "#${id.value}" is TextId -> id.value } fun main() { println(format(NumericId(7))) println(format(TextId("ada"))) }
function format(id: number | string): string { if (typeof id === "number") { return `#${id}`; } return id; } console.log(format(7)); console.log(format("ada"));
What costs Kotlin a sealed interface and two wrapper classes is a two-token type expression — no boxing, no .value unwrapping, callers pass the raw values. Unions compose anywhere a type goes (string | null was one), and narrowing splits them back apart. This is the single TypeScript feature Kotlin developers miss most when they return.
Sealed classes become discriminated unions
For payload-carrying variants, TypeScript's idiom is a union of object types sharing a literal kind field — and the compiler narrows on it exhaustively, like when over a sealed class.
sealed class Shape data class Circle(val radius: Double) : Shape() data class Rectangle(val width: Double, val height: Double) : Shape() fun area(shape: Shape): Double = when (shape) { is Circle -> 3.14159 * shape.radius * shape.radius is Rectangle -> shape.width * shape.height } fun main() { println(area(Rectangle(3.0, 4.0))) }
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return 3.14159 * shape.radius * shape.radius; case "rectangle": return shape.width * shape.height; default: { const impossible: never = shape; return impossible; } } } console.log(area({ kind: "rectangle", width: 3, height: 4 }));
Each case narrows shape to one variant — shape.radius only exists inside the circle branch, exactly like Kotlin's smart cast in a when (is …). The never trick is the exhaustiveness check: add a triangle variant and the default branch stops compiling, which is when's missing-branch error reconstructed from first principles. No classes, no inheritance — just shapes and a tag.
Objects & Classes
data class becomes a plain shape
The idiomatic TypeScript "record" is an interface plus object literals — no constructor, no generated members, and equality is reference identity.
data class Person(val name: String, val age: Int) fun main() { val person = Person("Ada", 36) println(person) println(person == Person("Ada", 36)) }
interface Person { readonly name: string; readonly age: number; } const person: Person = { name: "Ada", age: 36 }; console.log(person); console.log(person === { name: "Ada", age: 36 }); console.log(JSON.stringify(person) === JSON.stringify({ name: "Ada", age: 36 }));
The false stings: === compares references, and nothing generates an equals — structural typing does not mean structural equality. Real code compares field by field, JSON-stringifies (fragile: key order), or uses a library. readonly gives val-like fields, checked at compile time only. What you gain is literal ergonomics: no new, no class, shapes compose freely.
copy() is the spread
data class Person(val name: String, val age: Int) fun main() { val person = Person("Ada", 36) val older = person.copy(age = 37) println(older) println(person) val (name, age) = person println("$name is $age") }
interface Person { readonly name: string; readonly age: number; } const person: Person = { name: "Ada", age: 36 }; const older: Person = { ...person, age: 37 }; console.log(older); console.log(person); const { name, age } = person; console.log(`${name} is ${age}`);
The spread { ...person, age: 37 } is copy(age = 37) — a shallow copy with overrides, original untouched. Destructuring binds by property name (const { name, age }), not by position like Kotlin's componentN() — rename with { name: personName }. Both idioms are everyday TypeScript; nested objects share references, so deep updates need nesting spreads or a library.
Classes exist, with familiar sugar
TypeScript classes will feel like home — parameter properties even reproduce Kotlin's primary-constructor declarations — but the culture reaches for them far less often.
class Counter(private var count: Int = 0) { fun increment() { count += 1 } val current: Int get() = count } fun main() { val counter = Counter() counter.increment() counter.increment() println(counter.current) }
class Counter { constructor(private count: number = 0) {} increment(): void { this.count += 1; } get current(): number { return this.count; } } const counter = new Counter(); counter.increment(); counter.increment(); console.log(counter.current);
constructor(private count: number = 0) declares and assigns the field in one stroke — Kotlin's primary constructor, rediscovered. Getters, private (compile-time only; #count is the runtime-private spelling), and inheritance all exist. But where Kotlin defaults to classes, TypeScript defaults to interfaces + plain objects + standalone functions; classes appear mainly when identity or frameworks demand them.
Functions
Lambdas are arrow functions
fun main() { val double = { number: Int -> number * 2 } println(double(21)) val numbers = listOf(1, 2, 3).map { it * 10 } println(numbers) }
const double = (number: number): number => number * 2; console.log(double(21)); const numbers = [1, 2, 3].map((number) => number * 10); console.log(numbers);
No it exists — arrows always name their parameter, and single-expression bodies skip the braces and return like Kotlin's expression-body functions. Arrows also capture this lexically, defusing the classic JavaScript foot-gun; for a Kotlin developer the practical rule is simply "always use arrows for callbacks."
Defaults yes, named arguments no
Default parameter values carry over directly. Named arguments do not exist — the idiom is an options object, which destructuring makes almost as pleasant.
fun greet(name: String = "world", punctuation: String = "!") = "Hello, $name$punctuation" fun main() { println(greet()) println(greet(punctuation = "?")) }
function greet({ name = "world", punctuation = "!" } = {}): string { return `Hello, ${name}${punctuation}`; } console.log(greet()); console.log(greet({ punctuation: "?" }));
The destructured-options-with-defaults pattern ({ name = "world" } = {}) reconstructs Kotlin's named-and-defaulted parameters: call sites read like named arguments, omissions take defaults, and the types check. TypeScript also marks genuinely optional parameters with ? (function log(message: string, level?: string)), which types the parameter as string | undefined inside.
No extension functions
Kotlin's fun String.shout() has no TypeScript counterpart — extending built-in prototypes is possible and firmly taboo. Plain functions fill the role.
fun String.shouted(): String = uppercase() + "!" fun main() { println("hello".shouted()) }
function shouted(text: string): string { return text.toUpperCase() + "!"; } console.log(shouted("hello"));
Monkey-patching String.prototype would give the method syntax but mutates a global shared by every library on the page — the exact hazard Kotlin's statically-resolved extensions were designed to avoid. So TypeScript code writes shouted(text) where Kotlin writes text.shouted(), and pipelines lean on nesting or intermediate consts. (A pipe operator has been in committee for years; do not hold your breath.)
Arrays, Maps & readonly
Collection chains, nearly verbatim
fun main() { val result = listOf(1, 2, 3, 4, 5, 6) .filter { it % 2 == 0 } .map { it * it } .sum() println(result) }
const result = [1, 2, 3, 4, 5, 6] .filter((number) => number % 2 === 0) .map((number) => number * number) .reduce((total, number) => total + number, 0); console.log(result);
The chains translate directly and both run eagerly, allocating intermediate arrays (JavaScript iterators exist for the lazy case, playing asSequence()). Vocabulary: no sum()reduce with an initial value is Kotlin's fold. And use ===: JavaScript's == coerces types in ways Kotlin's never did.
mapOf becomes Map — not an object
JVM habits want a dictionary; JavaScript offers two: the Map class (the real dictionary) and plain objects (which look like one and leak).
fun main() { val ages = mutableMapOf("Ada" to 36) ages["Grace"] = 45 println(ages["Ada"]) println(ages["Alan"] ?: 0) println(ages.size) }
const ages = new Map([["Ada", 36]]); ages.set("Grace", 45); console.log(ages.get("Ada")); console.log(ages.get("Alan") ?? 0); console.log(ages.size); const record: Record<string, number> = { Ada: 36 }; console.log(record["Ada"]);
Map behaves like mutableMapOf: any key type, a real size, get returning V | undefined for the ?:-style default. The Record<string, number> object form is common for static string keys but inherits Object.prototype quirks and coerces keys to strings — reach for Map whenever keys are data. Set likewise stands in for mutableSetOf.
List vs MutableList becomes readonly
fun main() { val readOnly: List<Int> = listOf(1, 2, 3) // readOnly.add(4) ← does not compile val mutable = readOnly.toMutableList() mutable.add(4) println(mutable) }
const readOnly: readonly number[] = [1, 2, 3]; // readOnly.push(4); ← does not compile const mutable = [...readOnly]; mutable.push(4); console.log(mutable);
The readonly number[] type drops the mutating methods at compile time — Kotlin's List/MutableList split expressed as a type modifier, with the same caveat that it is a read-only view: the underlying array is one mutable object, and the annotation erases at runtime. The spread [...readOnly] is toMutableList(), a fresh shallow copy.
async/await, One Thread
suspend becomes async — on one thread
The syntax maps cleanly: suspend fun is async function, awaiting looks the same. The runtime does not: there are no threads to block, only one event loop.
import kotlinx.coroutines.* suspend fun fetchGreeting(): String { delay(10) return "Hello from a coroutine" } fun main() = runBlocking { val message = fetchGreeting() println(message) }
function fetchGreeting(): Promise<string> { return new Promise((resolve) => { setTimeout(() => resolve("Hello from a promise"), 10); }); } async function main(): Promise<void> { const message = await fetchGreeting(); console.log(message); } main();
An async function returns Promise<T> — Kotlin's Deferred — and await suspends without blocking, because blocking is not an option: JavaScript runs on a single thread, and anything CPU-heavy freezes everything. No Dispatchers, no thread confinement bugs, no runBlocking bridge — and no parallelism without worker processes. Concurrency is purely interleaved I/O, which for a web backend is most of the job.
awaitAll becomes Promise.all
import kotlinx.coroutines.* suspend fun compute(number: Int): Int { delay(10) return number * number } fun main() = runBlocking { val results = coroutineScope { (1..4).map { number -> async { compute(number) } }.awaitAll() } println(results) }
function compute(number: number): Promise<number> { return new Promise((resolve) => { setTimeout(() => resolve(number * number), 10); }); } async function main(): Promise<void> { const results = await Promise.all([1, 2, 3, 4].map(compute)); console.log(results); } main();
Promise.all preserves input order like awaitAll and rejects on the first failure — but unlike coroutineScope, it does not cancel the still-running siblings; promises are not cancellable, which is the structured-concurrency guarantee JavaScript lacks. Promise.allSettled collects every outcome, and Promise.race is the timeout-pattern building block (select's little cousin).
Generics & Mapped Types
Generics: familiar, erased, structural
fun <T : Comparable<T>> largest(items: List<T>): T = items.max() fun main() { println(largest(listOf(3, 7, 2))) println(largest(listOf("banana", "apple"))) }
function largest<T extends number | string>(items: T[]): T { let result = items[0]; for (const item of items) { if (item > result) { result = item; } } return result; } console.log(largest([3, 7, 2])); console.log(largest(["banana", "apple"]));
The shape is Kotlin's with extends for : — but the constraint is structural like everything else, so unions serve where Kotlin needs interfaces (Comparable has no counterpart; comparability is per-operator). Both languages erase generics at runtime; TypeScript just erases the rest of the types with them.
keyof and mapped types: no Kotlin equivalent
TypeScript can compute types from other types — take their keys, make every field optional, pick a subset. Kotlin's generics have nothing in this category.
data class Person(val name: String, val age: Int) // Kotlin needs a hand-written parallel type for partial updates: data class PersonPatch(val name: String? = null, val age: Int? = null) fun patched(person: Person, patch: PersonPatch) = person.copy( name = patch.name ?: person.name, age = patch.age ?: person.age, ) fun main() { println(patched(Person("Ada", 36), PersonPatch(age = 37))) }
interface Person { name: string; age: number; } function patched(person: Person, patch: Partial<Person>): Person { return { ...person, ...patch }; } console.log(patched({ name: "Ada", age: 36 }, { age: 37 })); const key: keyof Person = "name"; console.log(key);
Partial<Person> is a mapped type — mechanically "every field of Person, optional" — kept in sync with Person forever, where Kotlin's PersonPatch drifts the day someone adds a field. keyof, Pick, Omit, Record, and template-literal types make the type layer a small programming language of its own; this is the corner where TypeScript's type system is simply more powerful than Kotlin's, erased or not.
Errors
try/catch — with an unknown twist
Both languages have unchecked exceptions and the same try/catch/finally. The TypeScript twist: JavaScript can throw anything, so a catch binding is unknown.
fun safeDivide(numerator: Double, denominator: Double): Double { require(denominator != 0.0) { "cannot divide by zero" } return numerator / denominator } fun main() { try { println(safeDivide(10.0, 2.0)) println(safeDivide(1.0, 0.0)) } catch (exception: IllegalArgumentException) { println(exception.message) } }
function safeDivide(numerator: number, denominator: number): number { if (denominator === 0) { throw new Error("cannot divide by zero"); } return numerator / denominator; } try { console.log(safeDivide(10, 2)); console.log(safeDivide(1, 0)); } catch (error) { if (error instanceof Error) { console.log(error.message); } }
There is no typed catch clause — you catch everything, then narrow, because throw "oops" and throw 42 are legal JavaScript and libraries occasionally do it. The instanceof Error check works (classes, unlike interfaces, exist at runtime). Kotlin's Nothing-returning error() maps to a never-returning function; neither language has checked exceptions, and both ecosystems increasingly return result-shaped unions instead.