PONY λ M2 Modula-2

TypeScript.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing TypeScript and Kotlin

TypeScript 6.0 Kotlin 2.3
Variables & Types
let/const vs val/var
const maxRetries = 3; let attemptCount = 0; attemptCount = 1; console.log(maxRetries, attemptCount);
fun main() { val maxRetries = 3 // val reads like const: cannot be reassigned var attemptCount = 0 // var reads like let: can be reassigned attemptCount = 1 println("$maxRetries $attemptCount") }
Kotlin's val and var map directly onto TypeScript's const and let — the naming is even easier to remember, since "val" evokes "value" and "var" evokes "variable." Idiomatic Kotlin prefers val everywhere it can, exactly as idiomatic TypeScript prefers const.
Type inference
const count = 42; const message = "hello"; console.log(count, message);
fun main() { val count = 42 // inferred as Int val message = "hello" // inferred as String println("$count $message") }
Both languages infer the type of a variable from its initializer, and both fix that type permanently at declaration — TypeScript at compile time, Kotlin at compile time too, but Kotlin additionally carries the type into the compiled bytecode, so it stays enforced at runtime rather than being erased.
Explicit type annotations
const productName: string = "Widget"; const price: number = 9.99; const quantity: number = 100; const inStock: boolean = true; console.log(productName, price, quantity, inStock);
fun main() { val productName: String = "Widget" val price: Double = 9.99 val quantity: Int = 100 val inStock: Boolean = true println("$productName $price $quantity $inStock") }
The annotation syntax is identical — a colon after the name. The meaningful difference is underneath: TypeScript's number is one type covering every numeric value, while Kotlin distinguishes Int, Long, Double, and Float as genuinely different runtime types with different memory layouts.
Kotlin Any is not TypeScript any
let value: any = 42; value = "now a string"; value = [1, 2, 3]; // value.thisMethodDoesNotExist() would COMPILE with "any" and only // crash if actually executed — the compiler performs no member check at all. console.log(value);
fun main() { var value: Any = 42 value = "now a string" value = listOf(1, 2, 3) // value.thisMethodDoesNotExist() // compile error: unresolved reference println(value) }
These look similar but behave very differently. TypeScript's any disables type checking entirely — a call to a nonexistent member compiles without complaint and only fails, often confusingly, if that line actually executes at runtime. Kotlin's Any is simply the root of the type hierarchy (comparable to unknown, not any): you can hold anything in it, but the compiler still requires a cast or type check before calling a member, so a typo like this is caught before the program ever runs.
Null Safety
Erased vs runtime-enforced null safety
function getLength(text: string): number { return text.length; } const value: string = null as any; // bypasses strict-null-checks entirely console.log(getLength(value)); // TypeError at runtime, not caught by the type system
fun getLength(text: String): Int { return text.length } fun main() { val value: String? = null // getLength(value) // compile error: String? cannot be passed to String println(getLength(value ?: "")) }
This is the single biggest gap between the two languages. TypeScript's strict-null-checks is a compile-time-only discipline: it vanishes the moment the JavaScript runs, so as any, a loose external API, or a stale build can smuggle null/undefined straight through. Kotlin's String vs String? distinction is enforced by the compiler down to a real runtime guarantee — a non-nullable String genuinely cannot be null when the program is running, not just when it was type-checked.
Optional chaining ?. vs the safe-call operator ?.
const user: { name: string } | undefined = undefined; const length = user?.name?.length; console.log(length); // undefined
fun main() { val user: String? = null val length = user?.length println(length) // null val greeting: String? = "Hello" println(greeting?.length) // 5 println(greeting?.uppercase()) // HELLO }
The syntax ?. is identical in both languages and short-circuits the same way when the receiver is missing. The difference is what backs the guarantee: TypeScript's compiler tracks | undefined only in code it can see, and the check disappears from the emitted JavaScript, whereas Kotlin's ?. is checking a type that is genuinely part of the value's runtime representation.
Nullish coalescing ?? vs the Elvis operator ?:
const rawInput: string | undefined = undefined; const displayName = rawInput ?? "Anonymous"; console.log(displayName); const count: number | undefined = undefined; const result = count ?? 0; console.log(result);
fun main() { val rawInput: String? = null val displayName = rawInput ?: "Anonymous" println(displayName) val count: Int? = null val result = count ?: 0 println(result) val text: String? = null println(text?.length ?: -1) // pairs naturally with a safe call }
Kotlin's Elvis operator ?: — named for its resemblance to an emoticon of Elvis Presley's hairstyle — behaves exactly like TypeScript's ??: it substitutes the right-hand side only when the left side is null, not for every falsy value the way || would. The two operators are a near-perfect syntactic match, just spelled differently.
TypeScript ! vs Kotlin !! — both bypass safety, differently
function getLength(text: string | undefined): number { return text!.length; // ! tells the compiler "trust me", nothing checked at runtime } console.log(getLength(undefined)); // runtime TypeError, not an NPE with a clear message
fun main() { val possiblyNull: String? = null // !! asserts non-null — throws a NullPointerException immediately if wrong // val definitelyString: String = possiblyNull!! // println(definitelyString.length) // Prefer safe call + Elvis over !!: val saferLength = possiblyNull?.length ?: 0 println(saferLength) }
TypeScript's ! non-null assertion is purely a compile-time annotation — it is erased entirely, so a wrong assertion just lets undefined flow through until something downstream throws an unrelated error. Kotlin's !! is a real runtime check: it throws a NullPointerException immediately at the assertion site if the value actually is null, which is far easier to debug but still considered a code smell — idiomatic Kotlin reaches for ?. and ?: instead.
Type narrowing vs smart casts
function shout(text: string | undefined): string { if (text !== undefined) { return text.toUpperCase(); // narrowed to string within this block } return "(nothing)"; } console.log(shout("hello")); console.log(shout(undefined));
fun shout(text: String?): String { if (text != null) { return text.uppercase() // smart-cast to String within this block } return "(nothing)" } fun main() { println(shout("hello")) println(shout(null)) }
The pattern reads almost identically — narrow with an if check, use the narrowed value directly inside the block. TypeScript's narrowing is a static analysis performed purely by the compiler over erased types; Kotlin's smart cast is the same kind of static analysis, but layered on top of a type that is also enforced at runtime, so the two guarantees reinforce each other instead of one being purely cosmetic.
Strings
Template literals vs string templates
const name = "World"; const count = 42; const greeting = `Hello, ${name}! Count: ${count}.`; console.log(greeting);
fun main() { val name = "World" val count = 42 val greeting = "Hello, $name! Count: $count." // bare $name — no braces needed println(greeting) val sum = "2 + 2 = ${2 + 2}" // ${} still required for expressions println(sum) }
TypeScript always requires backticks and ${} for every interpolation, even a bare variable. Kotlin allows a bare $name for a simple identifier and reserves the ${expression} braces for anything more complex — a small but frequent syntactic convenience, and it works inside ordinary double-quoted strings rather than needing a special delimiter.
Multiline template literals vs raw strings
const path = "C:\\Users\\Alice"; // backslashes must be escaped const poem = `Roses are red, Violets are blue.`; console.log(path); console.log(poem);
fun main() { val path = """C:\Users\Alice""" // triple-quoted: no escaping needed val poem = """ Roses are red, Violets are blue. """.trimIndent() println(path) println(poem) }
Both languages support multiline string literals, but TypeScript template literals still process escape sequences, so a literal backslash needs doubling. Kotlin's triple-quoted strings are true raw strings — no escape processing at all — and pair with .trimIndent() to strip the common leading whitespace introduced by indenting the literal in source code.
Common string methods
const text = " Hello, World! "; console.log(text.trim()); console.log(text.trim().toLowerCase()); console.log("hello".includes("ell")); console.log("hello".startsWith("hel")); console.log("hello".repeat(3)); console.log("a,b,c".split(","));
fun main() { val text = " Hello, World! " println(text.trim()) println(text.trim().lowercase()) println("hello".contains("ell")) println("hello".startsWith("hel")) println("hello".repeat(3)) println("a,b,c".split(",")) }
The methods available are nearly a one-to-one match, with a handful of naming differences that trip up a TypeScript developer at first: lowercase()/uppercase() instead of toLowerCase()/toUpperCase(), and contains() instead of includes(). Both languages' strings are immutable — every method returns a new string.
Converting between strings and numbers
const parsed = parseInt("123", 10); console.log(parsed + 1); const price = parseFloat("9.99"); console.log(price * 2); const invalid = parseInt("not-a-number", 10); console.log(Number.isNaN(invalid)); // true
fun main() { val parsed = "123".toInt() println(parsed + 1) val price = "9.99".toDouble() println(price * 2) // Safe conversion: returns null instead of throwing val safe = "not-a-number".toIntOrNull() ?: 0 println(safe) }
Kotlin attaches conversion methods directly onto the string value instead of routing through global functions like parseInt. Where TypeScript's parseInt quietly returns NaN on unparseable input, Kotlin's toInt() throws a NumberFormatException — the safer alternative toIntOrNull() returns null instead, which then pairs naturally with the Elvis operator to supply a default.
Collections
Arrays vs Kotlin Lists — mutable by default vs opt-in
const fruits: string[] = ["apple", "banana", "cherry"]; fruits.push("date"); // always mutable at runtime, readonly is compile-time only console.log(fruits); console.log(fruits.length);
fun main() { // Read-only list — add()/remove() are compile errors, not just discouraged: val fruits = listOf("apple", "banana", "cherry") println(fruits) println(fruits.size) // Mutable list, explicitly opted into: val mutableFruits = mutableListOf("apple", "banana", "cherry") mutableFruits.add("date") println(mutableFruits) }
A TypeScript readonly string[] only blocks mutation at compile time — the underlying JavaScript array is always mutable, so a cast or an untyped caller can still push onto it. Kotlin's List versus MutableList distinction is a genuinely different runtime type: listOf() produces an object that has no add() method to call in the first place.
Map vs Kotlin Map
const scores = new Map<string, number>([ ["Alice", 95], ["Bob", 87], ]); scores.set("Carol", 92); console.log(scores.get("Alice")); console.log(scores.has("Eve")); console.log(scores.size);
fun main() { val scores = mutableMapOf( "Alice" to 95, "Bob" to 87, ) scores["Carol"] = 92 println(scores["Alice"]) // 95 println("Eve" in scores) // false println(scores.size) }
Kotlin builds a key-value pair with the infix function to ("Alice" to 95), rather than a two-element array literal or object literal. Subscript syntax works both for reading (scores["Alice"], matching .get()) and writing (scores["Carol"] = 92, matching .set()). As with lists, mapOf() is read-only and mutableMapOf() opts into mutation.
Set vs Kotlin Set
const tags = new Set(["typescript", "kotlin", "javascript"]); tags.add("java"); console.log(tags.has("typescript")); console.log(tags.size); tags.delete("javascript"); console.log([...tags]);
fun main() { val tags = mutableSetOf("typescript", "kotlin", "javascript") tags.add("java") println("typescript" in tags) // true, replaces .has() println(tags.size) tags.remove("javascript") // replaces .delete() println(tags) }
The Kotlin Set API is a close match for TypeScript's Set, with in replacing .has() and remove() replacing .delete(). Kotlin additionally distinguishes ordering guarantees at the type level: LinkedHashSet (the default, insertion-ordered), TreeSet (sorted), and plain HashSet (no ordering, fastest).
Array methods vs collection operations
const numbers = [1, 2, 3, 4, 5]; console.log(numbers.filter(n => n % 2 === 0)); // [2, 4] console.log(numbers.map(n => n * n)); // [1, 4, 9, 16, 25] console.log(numbers.reduce((sum, n) => sum + n, 0)); // 15 console.log(numbers.find(n => n > 3)); // 4
fun main() { val numbers = listOf(1, 2, 3, 4, 5) println(numbers.filter { it % 2 == 0 }) // [2, 4] println(numbers.map { it * it }) // [1, 4, 9, 16, 25] println(numbers.fold(0) { sum, n -> sum + n }) // 15 println(numbers.find { it > 3 }) // 4 }
This is one of the strongest bridge points between the two languages — filter, map, and find carry over almost unchanged. TypeScript's reduce(callback, initialValue) corresponds to Kotlin's fold(initialValue) { accumulator, element -> }, with the initial value moved to the front. Kotlin lambdas use trailing curly braces rather than parentheses, and a single implicit parameter is available as it.
Control Flow
if statement vs if expression
const score = 75; let grade: string; if (score >= 90) { grade = "A"; } else if (score >= 80) { grade = "B"; } else { grade = "C"; } console.log(grade); const ternaryGrade = score >= 90 ? "A" : score >= 80 ? "B" : "C"; console.log(ternaryGrade);
fun main() { val score = 75 // if is an expression in Kotlin — it directly produces a value: val grade = if (score >= 90) "A" else if (score >= 80) "B" else "C" println(grade) }
TypeScript needs either a mutable let variable assigned in every branch of an if statement, or a ternary chain for a value-producing conditional. Kotlin's if is itself an expression that yields a value directly, which eliminates the ternary operator entirely — Kotlin has none — while staying just as concise as the statement form for side-effecting code.
switch statement vs when expression
const day = "Monday"; let dayType: string; switch (day) { case "Saturday": case "Sunday": dayType = "weekend"; break; case "Monday": case "Friday": dayType = "busy"; break; default: dayType = "regular"; } console.log(dayType);
fun main() { val day = "Monday" val dayType = when (day) { "Saturday", "Sunday" -> "weekend" "Monday", "Friday" -> "busy" else -> "regular" } println(dayType) }
TypeScript's switch is a statement with fall-through by default, requiring break on every branch and a separately declared, mutable variable to capture a result. Kotlin's when is an expression — each branch ends at -> with no fall-through and no break, and it can be assigned directly to a val. This is a much closer parallel to a TypeScript discriminated-union narrowing pattern than to a switch statement.
when without a subject — replacing else-if chains
const temperature = 25; let description: string; if (temperature < 0) { description = "freezing"; } else if (temperature < 15) { description = "cold"; } else if (temperature < 25) { description = "mild"; } else { description = "warm"; } console.log(description);
fun main() { val temperature = 25 val description = when { temperature < 0 -> "freezing" temperature < 15 -> "cold" temperature < 25 -> "mild" else -> "warm" } println(description) }
A subjectless when { } behaves like an if/else if chain, where each branch is an arbitrary boolean condition rather than a value to match. This is strictly more expressive than a TypeScript switch, which can only match discrete values or a discriminant — Kotlin's when subsumes both roles in one construct.
for...of vs for (item in collection)
const colors = ["red", "green", "blue"]; for (const color of colors) { console.log(color); } for (const [index, color] of colors.entries()) { console.log(`${index}: ${color}`); }
fun main() { val colors = listOf("red", "green", "blue") for (color in colors) { println(color) } for ((index, color) in colors.withIndex()) { println("$index: $color") } }
Kotlin's for (item in collection) maps directly onto TypeScript's for (const item of collection) — both iterate values rather than indices. Kotlin's .withIndex() is the equivalent of .entries(), destructuring to (index, value) pairs; note Kotlin's in here is unrelated to the membership-testing in operator used elsewhere.
Functions
Function declaration
function greet(name: string): string { return `Hello, ${name}!`; } console.log(greet("World")); const square = (x: number): number => x * x; console.log(square(5));
fun greet(name: String): String { return "Hello, $name!" } // Single-expression shorthand — the return type is inferred: fun square(x: Int) = x * x fun main() { println(greet("World")) println(square(5)) }
Kotlin functions are declared with fun and place the return type after the parameter list with a colon, closely resembling a TypeScript function declaration's : ReturnType. Kotlin's single-expression shorthand (fun square(x: Int) = x * x) is the direct counterpart of a TypeScript implicit-return arrow function, but it also applies to named top-level functions, not just anonymous ones.
Default parameter values
function createGreeting(name: string, prefix = "Hello", punctuation = "!"): string { return `${prefix}, ${name}${punctuation}`; } console.log(createGreeting("Alice")); console.log(createGreeting("Bob", "Hi"));
fun createGreeting(name: String, prefix: String = "Hello", punctuation: String = "!"): String = "$prefix, $name$punctuation" fun main() { println(createGreeting("Alice")) println(createGreeting("Bob", "Hi")) }
Default parameter values work the same way in both languages — trailing parameters with defaults may be omitted at the call site. The difference appears with named arguments (the next concept): Kotlin lets you skip a default anywhere in the parameter list by name, while TypeScript still requires an explicit undefined to skip over a middle default when calling positionally.
Named arguments — no options-object workaround needed
interface ConnectOptions { host?: string; port?: number; database?: string; } function connect(options: ConnectOptions = {}): void { const { host = "localhost", port = 5432, database = "main" } = options; console.log(`Connecting to ${host}:${port}/${database}`); } connect({ port: 3306, database: "customers" }); connect({ host: "db.example.com" });
fun connect(host: String = "localhost", port: Int = 5432, database: String = "main") { println("Connecting to $host:$port/$database") } fun main() { connect(port = 3306, database = "customers") // named args, any order connect(host = "db.example.com") connect("db.example.com", database = "orders") // positional + named mix }
Kotlin has genuine named arguments as a language feature: any parameter may be specified by name, in any order, without wrapping everything in an options object. TypeScript developers reach for an options-object parameter — as in the anchor example — specifically to simulate what Kotlin already provides directly.
Rest parameters vs vararg
function sum(...numbers: number[]): number { return numbers.reduce((total, n) => total + n, 0); } console.log(sum(1, 2, 3)); const values = [5, 6, 7]; console.log(sum(...values)); // spread into a rest parameter
fun sum(vararg numbers: Int): Int = numbers.fold(0) { total, n -> total + n } fun main() { println(sum(1, 2, 3)) val values = intArrayOf(5, 6, 7) println(sum(*values)) // * is Kotlin's spread operator }
Kotlin's vararg keyword marks a parameter that accepts any number of arguments, matching TypeScript's rest parameter ...numbers. Both languages spread an existing array into the call with a prefix operator — TypeScript's ...values becomes Kotlin's *values. Unlike TypeScript, where the rest parameter must be last, Kotlin permits parameters after a vararg, provided they are supplied as named arguments.
Lambdas & Trailing Lambda Syntax
Arrow functions vs lambda syntax
const double = (x: number) => x * 2; const add = (a: number, b: number) => a + b; console.log(double(5), add(3, 4)); const numbers = [1, 2, 3]; console.log(numbers.map(x => x * 2));
fun main() { val double = { x: Int -> x * 2 } val add = { a: Int, b: Int -> a + b } println("${double(5)} ${add(3, 4)}") val numbers = listOf(1, 2, 3) println(numbers.map { it * 2 }) // 'it' — the implicit single parameter }
Kotlin lambdas are written entirely inside curly braces, with the parameter list (if any) before ->, rather than TypeScript's (params) => body. When a Kotlin lambda has exactly one parameter and it is left unnamed, it is available as it — TypeScript has no equivalent shorthand and always requires naming the parameter.
Arrow functions always in parens vs Kotlin trailing lambdas
function repeatAction(times: number, action: () => void): void { for (let i = 0; i < times; i++) action(); } // TypeScript: the callback always sits inside the parentheses repeatAction(3, () => console.log("hello"));
fun repeatAction(times: Int, action: () -> Unit) { for (i in 0 until times) action() } fun main() { repeatAction(3) { println("hello") } // trailing lambda — outside the parens }
When a Kotlin function's last parameter is a function type, the lambda argument can be moved outside the parentheses entirely — and if it is the only argument, the parentheses can be dropped altogether. TypeScript has no equivalent: a callback argument always stays inside the call's parentheses. This trailing-lambda convention is what makes Kotlin DSLs (like buildList { }) read like built-in syntax.
Function type syntax
function applyTwice(operation: (value: number) => number, value: number): number { return operation(operation(value)); } const triple = (x: number) => x * 3; console.log(applyTwice(triple, 2)); // 18
fun applyTwice(operation: (Int) -> Int, value: Int): Int = operation(operation(value)) fun main() { val triple = { x: Int -> x * 3 } println(applyTwice(triple, 2)) // 18 }
Kotlin's function type syntax, (ParameterType) -> ReturnType, closely mirrors TypeScript's (value: number) => number — the main difference is dropping the parameter names in the type position and using -> instead of =>. Both languages check the compatibility of a passed-in function against its declared type at compile time.
Closures
function makeCounter(start = 0) { let count = start; return { increment: () => ++count, value: () => count, }; } const counter = makeCounter(10); console.log(counter.increment()); // 11 console.log(counter.increment()); // 12
fun makeCounter(start: Int = 0): Map<String, () -> Int> { var count = start return mapOf( "increment" to { ++count }, "value" to { count }, ) } fun main() { val counter = makeCounter(10) println(counter["increment"]!!()) // 11 println(counter["increment"]!!()) // 12 }
Kotlin closures capture and can mutate a var from their enclosing scope, exactly as JavaScript/TypeScript closures capture a mutable let. A captured val is read-only from inside the closure, mirroring how a captured const cannot be reassigned inside an arrow function.
Classes & Interfaces
Constructor boilerplate vs the primary constructor
class Point { constructor(public x: number, public y: number) {} distanceTo(other: Point): number { return Math.hypot(this.x - other.x, this.y - other.y); } } const pointA = new Point(0, 0); const pointB = new Point(3, 4); console.log(pointA.distanceTo(pointB));
import kotlin.math.hypot class Point(val x: Double, val y: Double) { fun distanceTo(other: Point): Double = hypot(x - other.x, y - other.y) } fun main() { val pointA = Point(0.0, 0.0) val pointB = Point(3.0, 4.0) println(pointA.distanceTo(pointB)) }
TypeScript's constructor(public x: number, public y: number) {} shorthand for declaring-and-assigning fields already feels close to Kotlin, but Kotlin takes it further: the entire primary constructor lives in the class header, with no constructor keyword and no method body at all needed for simple cases. The new keyword is also gone — Point(0.0, 0.0) constructs directly.
Classes open by default vs final by default
class Base { greet(): void { console.log("Hello from Base"); } } class Child extends Base { override greet(): void { console.log("Hello from Child"); } } new Child().greet(); // Any class can be extended and any method overridden unless marked "final" // (a TypeScript keyword that exists but is rarely used).
fun main() { val child = Child() child.greet() } open class Base { open fun greet() = println("Hello from Base") // must opt in with 'open' } class Child : Base() { override fun greet() = println("Hello from Child") }
TypeScript classes and their methods are extendable and overridable by default; the rarely-used final keyword closes that off. Kotlin inverts the default: every class and every method is implicitly final, and inheritance or overriding must be explicitly opted into with open. This favors composition over inheritance and prevents the accidental-subclassing bugs that an open-by-default language allows.
TypeScript interface (structural) vs Kotlin interface (nominal, with defaults)
interface Speaker { speak(): string; } // Structural typing: any object shaped like Speaker satisfies it, // with no explicit "implements" required. const dog = { speak: () => "Woof", }; function announce(speaker: Speaker): string { return speaker.speak(); } console.log(announce(dog));
interface Speaker { fun speak(): String fun describe(): String = "A speaker: ${speak()}" // default implementation } class Dog : Speaker { override fun speak() = "Woof" } fun announce(speaker: Speaker): String = speaker.speak() fun main() { println(announce(Dog())) println(Dog().describe()) }
This is a genuine and easy-to-miss trap. TypeScript's interface is structural — any object with a matching shape satisfies it, with no declaration linking the two. Kotlin's interface is nominal — a class must explicitly declare : Speaker to satisfy it, even if its methods match exactly — and it can also carry default method implementations, which makes it closer in spirit to a TypeScript abstract class than to a TypeScript interface.
get accessors vs Kotlin computed properties
class Circle { constructor(public radius: number) {} get area(): number { return Math.PI * this.radius ** 2; } } const circle = new Circle(5); console.log(circle.area.toFixed(2));
import kotlin.math.PI class Circle(val radius: Double) { val area: Double get() = PI * radius * radius } fun main() { val circle = Circle(5.0) println("%.2f".format(circle.area)) }
TypeScript's get accessor and Kotlin's property with a custom get() are conceptually the same feature — a value that is computed on read but accessed with plain property syntax, no parentheses. The syntax differs: TypeScript nests the accessor inside the class body next to a method-like get area(), while Kotlin declares the property type first and attaches get() beneath it.
Data Classes
TypeScript types have no runtime equality vs data class equals()
interface User { name: string; email: string; age: number; } const alice: User = { name: "Alice", email: "alice@example.com", age: 30 }; const alice2: User = { name: "Alice", email: "alice@example.com", age: 30 }; console.log(alice === alice2); // false — reference comparison, always console.log(JSON.stringify(alice) === JSON.stringify(alice2)); // true, but a manual workaround
data class User(val name: String, val email: String, val age: Int) fun main() { val alice = User("Alice", "alice@example.com", 30) val alice2 = User("Alice", "alice@example.com", 30) println(alice === alice2) // false — reference identity, same as TS === println(alice == alice2) // true — structural equality, generated automatically println(alice) // User(name=Alice, email=alice@example.com, age=30) }
TypeScript's interface and type exist purely at compile time — they vanish before the code runs, so there is no automatic structural equality at runtime, and === always compares object references. Kotlin's data class generates a real equals() (and hashCode() and toString()) from the constructor properties, so == performs genuine value comparison. Reproducing this in TypeScript requires hand-written comparison code or a library like lodash.isEqual.
Hand-written immutable update vs copy()
interface User { readonly name: string; readonly email: string; readonly age: number; } const alice: User = { name: "Alice", email: "alice@example.com", age: 30 }; // No built-in way to update one field — spread and override by hand: const older: User = { ...alice, age: 31 }; console.log(alice, older);
data class User(val name: String, val email: String, val age: Int) fun main() { val alice = User("Alice", "alice@example.com", 30) val older = alice.copy(age = 31) // only the changed field needs naming println(alice) println(older) }
TypeScript developers reach for object-spread ({ ...alice, age: 31 }) to build a modified copy, since there is no built-in method that does this automatically for a plain type. Kotlin's data class generates a copy() method for free, taking named-argument overrides for just the fields that changed — the equivalent of the spread pattern, but generated rather than hand-written and available on every data class without extra code.
Object destructuring vs positional destructuring
interface Point { x: number; y: number; } const point: Point = { x: 3, y: 4 }; const { x, y } = point; // destructures by property NAME console.log(x, y);
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) val (x, y) = point // destructures by POSITION, via componentN() functions println("$x $y") }
TypeScript object destructuring matches by property name, so field order in the pattern does not matter. Kotlin data-class destructuring is positional — it calls the auto-generated component1(), component2(), and so on, in declaration order — so val (x, y) = point only works correctly if the pattern's order matches the constructor's order.
Extension Functions
Module augmentation vs a real extension function
// TypeScript's closest analog: augment the global String interface. // This mutates a SHARED global type for every consumer of the module — // there is no scoped way to add a method only where you import it. declare global { interface String { isPalindrome(): boolean; } } String.prototype.isPalindrome = function (): boolean { return this === [...this].reverse().join(""); }; console.log("racecar".isPalindrome());
// A real Kotlin language feature — scoped to wherever it is imported: fun String.isPalindrome(): Boolean = this == this.reversed() fun main() { println("racecar".isPalindrome()) println("hello".isPalindrome()) }
TypeScript has no true extension-function feature. Its closest analog, module augmentation of a global interface plus mutating String.prototype, is much weaker: it patches a single shared prototype for the entire program, cannot be scoped to one file, and risks colliding with other code doing the same thing. Kotlin extension functions are resolved statically at the call site and can be imported (and thus scoped) independently per file, with zero risk of a global collision.
Extension functions on your own or third-party types
interface Rectangle { width: number; height: number; } // TypeScript: a free function is the idiomatic alternative function area(rectangle: Rectangle): number { return rectangle.width * rectangle.height; } const rectangle: Rectangle = { width: 3, height: 4 }; console.log(area(rectangle));
data class Rectangle(val width: Double, val height: Double) fun Rectangle.area(): Double = width * height // reads like a member, is not one fun main() { val rectangle = Rectangle(3.0, 4.0) println(rectangle.area()) // called exactly like a real method }
Where TypeScript idiomatically reaches for a free function (area(rectangle)) to add behavior without touching the original type, Kotlin's extension function syntax, fun Rectangle.area(), lets the same free function be called with member syntax (rectangle.area()). It compiles down to a static function taking the receiver as its first argument — genuinely new syntax, not new dispatch behavior, but a real, first-class part of the language rather than a workaround.
Sealed Classes & when Exhaustiveness
Discriminated unions vs sealed classes
type Shape = | { kind: "circle"; radius: number } | { kind: "rectangle"; width: number; height: number }; function area(shape: Shape): number { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "rectangle": return shape.width * shape.height; } } console.log(area({ kind: "rectangle", width: 3, height: 4 }));
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 -> Math.PI * shape.radius * shape.radius is Rectangle -> shape.width * shape.height // no 'else' needed — the compiler knows these are the only two subtypes } fun main() { println(area(Rectangle(3.0, 4.0))) }
This is the strongest direct parallel between the two languages. A TypeScript discriminated union closed over a literal kind tag corresponds almost exactly to a Kotlin sealed class hierarchy, and both give the compiler enough information to check exhaustiveness — TypeScript when a switch is checked against `never` in the default case, Kotlin automatically for any when used as an expression over a sealed type, with no extra code required.
Exhaustiveness checking: opt-in vs automatic
type Result = | { status: "ok"; value: number } | { status: "error"; message: string }; function describe(result: Result): string { switch (result.status) { case "ok": return `Value: ${result.value}`; case "error": return `Error: ${result.message}`; default: // Exhaustiveness relies on this line failing to type-check // if a new variant is ever added — an opt-in pattern, easy to omit. const exhaustiveCheck: never = result; throw new Error(`Unhandled: ${exhaustiveCheck}`); } } console.log(describe({ status: "ok", value: 42 }));
sealed class Result data class Ok(val value: Int) : Result() data class Error(val message: String) : Result() fun describe(result: Result): String = when (result) { is Ok -> "Value: ${result.value}" is Error -> "Error: ${result.message}" // Compile error automatically if a new Result subtype is added // and this 'when' is not updated — no boilerplate 'never' check needed. } fun main() { println(describe(Ok(42))) }
TypeScript's exhaustiveness checking is a pattern the developer must opt into deliberately, typically the const exhaustiveCheck: never = value trick in a default case — omit it and an unhandled variant compiles silently. Kotlin's exhaustiveness check on a when expression over a sealed class is automatic and mandatory: the compiler itself refuses to compile a non-exhaustive when expression, with no idiom to remember or forget.
Sealed interfaces for polymorphic hierarchies
interface Shape { readonly kind: string; } interface Circle extends Shape { kind: "circle"; radius: number; } interface Square extends Shape { kind: "square"; side: number; } type AnyShape = Circle | Square; function describe(shape: AnyShape): string { return shape.kind === "circle" ? `circle r=${shape.radius}` : `square s=${shape.side}`; } console.log(describe({ kind: "square", side: 4 }));
sealed interface Shape data class Circle(val radius: Double) : Shape data class Square(val side: Double) : Shape fun describe(shape: Shape): String = when (shape) { is Circle -> "circle r=${shape.radius}" is Square -> "square s=${shape.side}" } fun main() { println(describe(Square(4.0))) }
Kotlin's sealed interface lets unrelated classes implement a shared, closed hierarchy — useful when the variants are not naturally related by inheritance. This is functionally close to a TypeScript union of interfaces sharing a discriminant field, but Kotlin enforces the closed set at compile time: every implementing class must be known and visible to the compiler in the same module, whereas TypeScript's union is open to any type matching the shape unless deliberately restricted.
Coroutines
async/await vs suspend functions
async function fetchUser(userId: number): Promise<string> { return `User #${userId}`; } (async () => { const user = await fetchUser(42); console.log(`${user} (processed)`); })();
// suspend functions read like synchronous code, same shape as async/await suspend fun fetchUser(userId: Int): String { // kotlinx.coroutines.delay(100) would be a non-blocking delay here return "User #$userId" } fun main() { kotlinx.coroutines.runBlocking { val user = fetchUser(42) // no explicit 'await' keyword — just call it println("$user (processed)") } }
The surface syntax is a genuinely good bridge point: Kotlin's suspend fun reads much like an async function, and calling a suspend function inside a coroutine scope reads like calling an async function with await already applied — Kotlin has no separate await keyword because calling a suspend function implicitly suspends until the result is ready. The deeper difference is structural concurrency (the next concept): a JavaScript Promise starts running the instant it is created and nothing tracks whether anyone is still waiting on it, while Kotlin coroutines are owned by a scope that can cancel them as a group.
Promise.all vs structured concurrency with coroutineScope
async function compute(value: number): Promise<number> { return value * value; } (async () => { // Promise.all races both, but if one throws, the other keeps running // in the background with no way to cancel it from here. const [first, second] = await Promise.all([compute(3), compute(4)]); console.log(first + second); })();
import kotlinx.coroutines.* suspend fun compute(value: Int): Int = value * value suspend fun main() = coroutineScope { val first = async { compute(3) } // starts immediately, in parallel val second = async { compute(4) } val result = first.await() + second.await() println(result) // coroutineScope guarantees BOTH children finish (or are cancelled // together) before this function returns — no orphaned work. }
This is Kotlin's deepest advantage over promises: coroutineScope enforces structured concurrency, meaning every coroutine launched inside it is guaranteed to either complete or be cancelled before the scope itself returns — there is no way for a child coroutine to silently outlive its parent. JavaScript's Promise.all offers no such guarantee: if one promise rejects, the other keeps executing in the background with no owner and no way to cancel it, a class of resource leak that structured concurrency eliminates by construction.
Error Handling
try/catch statement vs try expression
function parseCount(input: string): number { let value: number; try { value = Number.parseInt(input, 10); if (Number.isNaN(value)) throw new Error("not a number"); } catch { value = -1; // mutable variable declared before the try, assigned in both paths } return value; } console.log(parseCount("abc"));
fun parseCount(input: String): Int { val value = try { input.toInt() } catch (error: NumberFormatException) { -1 // the last expression in the catch block becomes the result } return value } fun main() { println(parseCount("abc")) }
TypeScript's try/catch is purely a statement, so recovering a value out of it requires declaring a mutable variable beforehand and assigning it in every branch. Kotlin's try/catch is an expression — the last expression evaluated in whichever block ran becomes the value of the whole try — which lets the result be bound directly to a val with no mutable intermediate.
Catch-clause narrowing vs runCatching
function parse(input: string): number { return JSON.parse(input); } try { console.log(parse("42")); } catch (error) { // TypeScript: caught errors are typed "unknown" — must be narrowed manually const message = error instanceof Error ? error.message : String(error); console.log(message); }
fun parse(input: String) = input.toInt() fun main() { val goodResult = runCatching { parse("42") } val badResult = runCatching { parse("abc") } println(goodResult.getOrElse { -1 }) // 42 println(badResult.getOrElse { -1 }) // -1 println(badResult.exceptionOrNull()?.message) }
TypeScript catches every error as unknown and requires an instanceof Error check before touching .message safely. Kotlin's runCatching { } sidesteps try/catch altogether by wrapping the block in a Result<T> value — either a success or a failure carrying the actual Throwable — with combinators like getOrElse and exceptionOrNull() to extract what you need without manual narrowing.
Custom Error subclasses vs custom exceptions
class InsufficientFundsError extends Error { constructor(public readonly shortfall: number) { super(`Need $${shortfall} more`); this.name = "InsufficientFundsError"; } } function withdraw(balance: number, amount: number): void { if (amount > balance) throw new InsufficientFundsError(amount - balance); console.log(`Withdrew $${amount}`); } try { withdraw(50, 100); } catch (error) { if (error instanceof InsufficientFundsError) { console.log(error.message, error.shortfall); } }
class InsufficientFundsException(val shortfall: Double) : Exception("Need $$shortfall more") fun withdraw(balance: Double, amount: Double) { if (amount > balance) throw InsufficientFundsException(amount - balance) println("Withdrew $$amount") } fun main() { try { withdraw(50.0, 100.0) } catch (error: InsufficientFundsException) { println(error.message) println("Shortfall: ${error.shortfall}") } }
Both languages let you define a custom error type carrying extra data. Kotlin's primary-constructor syntax combines the field declaration, constructor parameter, and property access into one line (val shortfall: Double), where TypeScript needs a separate public readonly shortfall: number constructor parameter plus a call to super(). Kotlin also requires the caught exception's type to be stated explicitly in catch (error: InsufficientFundsException) — there is no untyped catch parameter the way TypeScript's catch (error) defaults to unknown.
Gotchas for TypeScript Programmers
Gotcha: !! crashes loudly instead of propagating undefined
function getFirstChar(text: string | undefined): string { return text![0]; // ! is erased; on undefined this throws deep inside, // often far from where the bad value originated } try { console.log(getFirstChar(undefined)); } catch (error) { console.log("Crashed:", (error as Error).message); }
fun getFirstChar(text: String?): Char { return text!![0] // throws a clear NullPointerException right here, // at the exact site of the bad assumption } fun main() { try { println(getFirstChar(null)) } catch (error: NullPointerException) { println("Crashed: ${error.message}") } }
A TypeScript programmer moving to Kotlin should expect !! to fail loudly and immediately: because Kotlin's null safety is real at runtime, asserting non-null on an actual null throws a NullPointerException at the exact line of the assertion, rather than letting an undefined silently propagate downstream the way an erased TypeScript ! would. This is more disruptive in the moment but far easier to debug — the stack trace points straight at the bad assumption.
Gotcha: far more constructs are expressions in Kotlin
// TypeScript: if, switch, and try are statements, not expressions. // Getting a value out of any of them requires a mutable variable // or restructuring into a ternary / immediately-invoked function. function classify(value: number): string { let result: string; if (value < 0) { result = "negative"; } else { try { result = value === 0 ? "zero" : "positive"; } catch { result = "error"; } } return result; } console.log(classify(5));
fun classify(value: Int): String { // if, when, and try are all expressions in Kotlin — this whole // function body is a single expression with no mutable variable: return if (value < 0) { "negative" } else { try { if (value == 0) "zero" else "positive" } catch (error: Exception) { "error" } } } fun main() { println(classify(5)) }
A recurring surprise moving from TypeScript: Kotlin treats if, when, and try as expressions far more consistently than TypeScript treats if, switch, and try as statements. Code that needed a mutable variable declared up front and assigned inside each branch in TypeScript often collapses into a single expression in Kotlin — a difference in habit more than in individual syntax, but one that changes how idiomatic Kotlin code is structured throughout.
Gotcha: Kotlin has no structural typing at all
interface Point { x: number; y: number; } // TypeScript: this plain object literal satisfies Point automatically, // with no "implements" clause anywhere — pure structural matching. function distance(point: Point): number { return Math.hypot(point.x, point.y); } console.log(distance({ x: 3, y: 4 }));
interface Point { val x: Double val y: Double } // Kotlin: an object literal cannot satisfy Point on shape alone. // A class must explicitly declare ": Point" (or use an anonymous object). class ConcretePoint(override val x: Double, override val y: Double) : Point fun distance(point: Point): Double = Math.hypot(point.x, point.y) fun main() { println(distance(ConcretePoint(3.0, 4.0))) }
TypeScript's type system is fundamentally structural: any value with a matching shape satisfies an interface, with no declared relationship required. Kotlin is nominally typed throughout: a type only satisfies an interface if some class explicitly declares that it implements it. An object literal that happens to have an x and a y property does not satisfy a Kotlin Point interface — there is no anonymous-object equivalent of duck typing here.
Gotcha: properties can hide logic that TypeScript fields cannot
class Temperature { private _celsius: number; constructor(celsius: number) { this._celsius = celsius; } get fahrenheit(): number { return this._celsius * 9 / 5 + 32; } set fahrenheit(value: number) { this._celsius = (value - 32) * 5 / 9; } } const temperature = new Temperature(20); console.log(temperature.fahrenheit); temperature.fahrenheit = 100; console.log(temperature.fahrenheit);
class Temperature(private var celsius: Double) { var fahrenheit: Double get() = celsius * 9 / 5 + 32 set(value) { celsius = (value - 32) * 5 / 9 } } fun main() { val temperature = Temperature(20.0) println(temperature.fahrenheit) temperature.fahrenheit = 100.0 // looks like a plain field assignment, println(temperature.fahrenheit) // but silently runs the custom setter }
TypeScript's get/set accessors exist too, but a plain public field and a getter/setter pair look visibly different at the declaration site. In Kotlin every val/var declared as a class member is technically a property with a default getter (and setter, for var) — replacing them with custom logic changes nothing about how the property is accessed from outside. A TypeScript programmer reading temperature.fahrenheit = 100.0 should not assume it is a simple field write; it might silently trigger arbitrary code.