PONYλM2Modula-2

Kotlin.CodeCompared.To/JavaScript

An interactive executable cheatsheet comparing Kotlin and JavaScript

Kotlin 2.4 JavaScript
Output & Running
Hello, World — and no entry point
A JavaScript file has no main. The file is the program: statements run top to bottom as the engine reaches them, which is closer to a Kotlin script (.kts) than to a compiled module.
fun main() { println("Hello, World!") }
console.log("Hello, World!");
There is no compile step, so the first thing you learn about a mistake is when execution reaches it — including a misspelled method name. console.log takes several arguments and separates them with spaces, and writes to stdout; console.error writes to stderr. What has no counterpart is println's guarantee that the value has a sensible text form: console.log of an object prints an engine-specific rendering rather than calling anything you defined.
String templates
Template literals interpolate any expression, so Kotlin's "$name" and "${expression}" both carry over — with backticks around the whole string and a dollar-brace for every expression.
fun main() { val name = "Ada" val scores = listOf(90, 85) println("$name scored ${scores.sum()}") println("%.2f".format(3.14159)) }
const name = "Ada"; const scores = [90, 85]; console.log(`${name} scored ${scores.reduce((total, score) => total + score, 0)}`); console.log((3.14159).toFixed(2));
Two things do not carry over. There is no format and no format string at all: width, precision and padding are method calls on the value (toFixed, padStart, toLocaleString). And there is no sum(): reductions are reduce with an explicit initial value, which throws on an empty array if you omit it.
Nothing checks the program before it runs
This is the change of habit everything else follows from. Kotlin decides at compile time what JavaScript decides as each line executes — including whether a method exists at all.
fun main() { val count = 3 // println(count + "one") // uncomment: type mismatch, no bytecode println(count) }
const count = 3; console.log(count + "one"); // "3one" — a defined operation, not an error try { count.toFixd(2); // the typo is invisible until this line runs } catch (error) { console.log(error.constructor.name + ": count.toFixd is not a function"); }
Adding a number to a string is a defined operation rather than a type mismatch, and a misspelled method is a TypeError reached only if that branch runs. Two consequences: your test coverage is now the type checker, and TypeScript exists to give some of it back — it is the same language with a checker on top, erased before execution. A Kotlin/JS build gives you the same guarantee from the Kotlin side, for the code you wrote in Kotlin.
Null Safety, and Two Empties
undefined is a second kind of nothing
Kotlin has one null and a type system that tracks where it may appear. JavaScript has two empty values and tracks neither.
fun main() { val host: String? = null println(host ?: "localhost") val port: Int? = 5432 println(port ?: 5432) // There is one null. The other column has two empties. }
const config = { host: null, port: 5432 }; console.log(config.host ?? "localhost"); // null → fallback console.log(config.port ?? 5432); // present → itself console.log(config.missing ?? "localhost"); // undefined → fallback too console.log(typeof null, typeof undefined);
undefined is what you get from a missing property, a missing argument, or a function with no return; null is what a programmer writes to mean "deliberately empty". ?? is the elvis operator ?: and treats both alike, which is what you want. Note typeof null is "object" — a fifteen-year-old bug that cannot be fixed without breaking the web. The idiomatic "is there anything here" test is value != null, which is true for exactly those two values and nothing else.
The type no longer says whether it can be null
This is the loss to state plainly. In Kotlin String and String? are different types and the compiler refuses to let you confuse them; in JavaScript every value may be either, and nothing checks.
fun find(names: List<String>, target: String): String? = names.firstOrNull { it == target } fun main() { val found: String? = find(listOf("ada"), "bob") // println(found.length) // uncomment: only safe (?.) or non-null asserted (!!) calls allowed println(found?.length ?: -1) }
function find(names, target) { return names.find((name) => name === target) ?? null; } const found = find(["ada"], "bob"); console.log(found.length); // would throw — nothing warned you
The JavaScript as written throws TypeError: Cannot read properties of null at run time, which is the whole point of the comparison — and why it is shown rather than run. There is no compiler to tell you, so the discipline moves into the code: ?. and ?? exist and read exactly like Kotlin's ?. and ?:, and the habit to keep is using them everywhere a value crossed a boundary. TypeScript with strictNullChecks restores most of the guarantee — it is the closest thing the platform has to what you are giving up.
?. and ?: carry over exactly
The two operators you use most survive the move with the same spelling and the same short-circuiting.
class Address(val city: String?) class Customer(val address: Address?) class Order(val customer: Customer?) fun main() { val order = Order(Customer(Address(null))) println(order.customer?.address?.city ?: "unknown") }
class Address { constructor(city) { this.city = city; } } class Customer { constructor(address) { this.address = address; } } class Order { constructor(customer) { this.customer = customer; } } const order = new Order(new Customer(new Address(null))); console.log(order.customer?.address?.city ?? "unknown");
?. stops the chain at the first empty and yields undefined rather than null, which matters only if you compare with ===. ?? is ?: and falls back only on the two empties, never on 0 or "" — unlike ||, which is the older idiom and the source of the classic "zero became the default" bug. There is also ?.[index] and ?.() for optional indexing and calling, which Kotlin has no need for.
let, also, apply and run have no counterparts
The scope functions are Kotlin standard-library conveniences, not language features, and JavaScript ships nothing like them.
fun main() { val name: String? = "Ada" name?.let { println("hello $it") } val greeting = buildString { append("hello") append(", world") } println(greeting) }
const name = "Ada"; if (name != null) console.log(`hello ${name}`); const parts = []; parts.push("hello"); parts.push(", world"); console.log(parts.join(""));
?.let { } becomes an if; apply and also become plain statements; run and with become an IIFE if you really want an expression. it has no equivalent either — every lambda parameter is named. The nearest thing in spirit is optional chaining plus ??, and the honest advice is to stop reaching for the pattern rather than to reproduce it: JavaScript code that simulates apply reads worse than the four plain lines it replaced.
What replaces lateinit and by lazy
Two Kotlin idioms for "this property gets its value later" have no keyword here: a field that will be set before use, and one computed on first read.
class Service { lateinit var connection: String val expensive: Int by lazy { println("computed once") 42 } } fun main() { val service = Service() service.connection = "ready" println(service.connection) println(service.expensive) println(service.expensive) }
class Service { #expensive; get expensive() { if (this.#expensive === undefined) { console.log("computed once"); this.#expensive = 42; } return this.#expensive; } } const service = new Service(); service.connection = "ready"; // no declaration needed, and none checked console.log(service.connection); console.log(service.expensive); console.log(service.expensive);
lateinit becomes nothing at all — an undeclared property is simply assigned, and reading it early gives undefined instead of the UninitializedPropertyAccessException that told you exactly what went wrong. by lazy becomes a getter with a cache, as above, and is not thread-safe because it does not need to be: there is one thread. Delegated properties in general (by) have no counterpart; the closest mechanism is Object.defineProperty or a Proxy, both of which are heavier than what they replace.
Values, Types & Equality
const is not val
The keywords line up almost exactly — val is const and var is let — and the thing Kotlin gives you on top is the read-only type, not the keyword.
fun main() { val total = 1 var count = 0 count += 1 val items = mutableListOf(1, 2) items.add(3) // val is about the BINDING here too println("$total $count ${items.size}") }
const total = 1; let count = 0; count += 1; const items = [1, 2]; items.push(3); // legal: the binding is const, not the array console.log(total, count, items.length);
Kotlin's val stops reassignment, exactly as const does; what stops mutation is asking for List rather than MutableList. JavaScript has no read-only collection type at all, so a const array is fully mutable and the only run-time defence is Object.freeze, which is shallow. Use const by default — it is the linter default and the idiomatic choice — but do not read it as a promise about the value.
Two symbols, three meanings
The same two symbols mean different things in the two languages, and the mismatch is worth memorising because it is silent.
fun main() { val first = listOf(1, 2, 3) val second = listOf(1, 2, 3) println(first === second) // referential println(first == second) // structural: calls equals }
const first = [1, 2, 3]; const second = [1, 2, 3]; console.log(first === second); // identity — Kotlin's === console.log(JSON.stringify(first) === JSON.stringify(second)); // the contents workaround console.log(0 == "0", 0 === "0"); // == converts; === does not
Kotlin's == calls equals (structural) and === is referential. JavaScript's === is Kotlin's ===, and JavaScript's == is neither — it converts its operands before comparing, which is why 0 == "0" is true and why the rule is to never write it. There is no structural equality for arrays or objects: two identical arrays are unequal, and the usual workaround is JSON.stringify on both, which is wrong for key order and for anything JSON cannot represent.
is becomes typeof and instanceof
Type tests exist, and they are three different mechanisms rather than one keyword — with a couple of famously imperfect answers.
fun describe(value: Any): String = when (value) { is String -> "string of ${value.length}" is Int -> "an integer" is List<*> -> "a list of ${value.size}" else -> "something else" } fun main() { println(describe("hello")) println(describe(42)) println(describe(listOf(1, 2))) }
function describe(value) { if (typeof value === "string") return `string of ${value.length}`; if (Number.isInteger(value)) return "an integer"; if (Array.isArray(value)) return `a list of ${value.length}`; return "something else"; } console.log(describe("hello")); console.log(describe(42)); console.log(describe([1, 2]));
typeof answers for the seven primitives and is the one to use for strings, numbers and booleans; instanceof answers for classes and fails across realms (an array from an iframe is not instanceof Array, which is why Array.isArray exists). typeof null is "object" and typeof [] is "object". What is genuinely missing is smart casting: after typeof value === "string" nothing changes about what you may call — there is no type to narrow, so nothing stops you calling a string method in the wrong branch.
Destructuring goes further
Kotlin destructures positionally, by componentN(). JavaScript destructures by name for objects and by position for arrays, and carries default values.
data class Point(val x: Int, val y: Int) fun main() { val (x, y) = Point(1, 2) println("$x $y") val (first, second) = listOf(10, 20) println("$first $second") }
const { x, y } = { x: 1, y: 2 }; console.log(x, y); const [first, ...rest] = [10, 20, 30]; console.log(first, rest.join(",")); const { host = "localhost", port = 5432 } = { host: "db" }; console.log(host, port);
That name-based form is what Kotlin's data-class destructuring is often mistaken for and is not: reordering a Kotlin data class's properties silently changes what val (x, y) binds, while the JavaScript object pattern is immune. The array form adds ...rest, defaults, and nesting, and works in function parameters — which is how the options-object idiom stays readable. There is no componentN convention and no destructuring of arbitrary classes.
Any, Unit and Nothing
Kotlin's three special types collapse into the untyped default plus undefined.
fun log(message: Any): Unit { println(message) } fun main() { log("a string") log(42) val result: Unit = log("unit is a real value") println(result) }
function log(message) { console.log(message); } log("a string"); log(42); const result = log("undefined is what you get"); console.log(result);
Any becomes "no annotation at all". Unit becomes undefined, which a function with no return yields — but where Unit is a real singleton value with a type, undefined is the same value you get from a missing property, so "returned nothing" and "that property does not exist" are indistinguishable. Nothing has no counterpart; a function that always throws simply has no return value to describe. TypeScript restores all three as unknown, void and never.
Numbers
Every number is a Double
There is one numeric type and it is IEEE 754 double precision. Int, Long, Float and the distinction between 7 / 2 and 7 / 2.0 all disappear.
fun main() { val count = 7 println(count / 2) // Int division println(count / 2.0) println(Int.MAX_VALUE + 1) // wraps, silently }
const count = 7; console.log(Math.trunc(count / 2)); console.log(count / 2); console.log(2 ** 31); // nothing is 32 bits wide
Division always produces a float, so integer division is Math.trunc(a / b) — or Math.floor, which differs for negatives exactly as Kotlin's floorDiv does. Integers are exact only up to 2⁵³ (Number.MAX_SAFE_INTEGER), past which additions silently round rather than wrapping as a Kotlin Int would. The bitwise operators convert to 32-bit signed integers first, which is the strangest corner of the type.
A Long does not fit
This is the row that bites a Kotlin Multiplatform build, and it is worth knowing before the bug arrives.
fun main() { val big = 9_007_199_254_740_993L // 2^53 + 1 println(big) println(big.toDouble().toLong()) // the round trip loses it }
const big = 9007199254740993n; // the n suffix makes a BigInt console.log(big.toString()); console.log(Number(big)); // through a double: 9007199254740992 // console.log(big + 1); // TypeError: cannot mix BigInt and number
A Long cannot be represented exactly by a JavaScript number past 2⁵³, so Kotlin/JS compiles Long to a boxed implementation rather than a native number — correct, and not something you can hand to a JavaScript caller as a plain value. BigInt is the platform's arbitrary-precision integer, and it deliberately refuses to mix with number in arithmetic; it also cannot be JSON.stringifyd without a custom replacer. The practical advice for an exported API: use Int, or a String, and never a Long.
Ranges have no counterpart
There is no range type, so .., until, downTo, step and in all become the C-style for loop and explicit comparisons.
fun main() { for (index in 0..2) print("$index ") println() for (index in 0 until 3) print("$index ") println() for (index in 6 downTo 0 step 2) print("$index ") println() println(5 in 1..10) }
for (let index = 0; index <= 2; index++) process.stdout.write(index + " "); console.log(); for (let index = 0; index < 3; index++) process.stdout.write(index + " "); console.log(); for (let index = 6; index >= 0; index -= 2) process.stdout.write(index + " "); console.log(); console.log(5 >= 1 && 5 <= 10);
The nearest thing to (0..9).toList() is Array.from({ length: 10 }, (_, index) => index), which is worth knowing and not worth loving. Note the inclusivity trap while translating: Kotlin's .. includes its end and until excludes it, so a mechanical conversion to < is right for one and wrong for the other. Ranges are also where when loses power, which the sealed-classes section returns to.
Strings
The everyday string methods
Nearly every string method you know exists with a slightly different name, and the shapes are close enough to guess.
fun main() { val title = " Hello, World " println(title.trim()) println(title.trim().uppercase()) println(title.replace("World", "JS").trim()) println(title.contains("World")) println("a,b,c".split(",").joinToString("-")) }
const title = " Hello, World "; console.log(title.trim()); console.log(title.trim().toUpperCase()); console.log(title.replace("World", "JS").trim()); console.log(title.includes("World")); console.log("a,b,c".split(",").join("-"));
uppercase() is toUpperCase(), contains is includes, joinToString is join (with no separator argument by default — it uses a comma, unlike Kotlin's ", "). The trap is replace: Kotlin's replaces every occurrence, while JavaScript's replace with a string argument replaces only the first — use replaceAll, or a /g regex, to get Kotlin's behaviour.
Strings are immutable in both, and indexed differently
Both languages hold strings as UTF-16 and both are immutable, so the counting surprises are the same ones — which makes this a rare row where nothing new is being lost.
fun main() { val word = "naïve" println(word.length) println(word[0]) println(word.substring(0, 3)) println("🦀".length) }
const word = "naïve"; console.log(word.length); console.log(word[0]); console.log(word.slice(0, 3)); console.log("🦀".length);
A JVM String and a JavaScript string are both sequences of UTF-16 code units, so "🦀".length is 2 in both: an emoji outside the Basic Multilingual Plane takes two units, and slicing between them produces a lone surrogate. substring(start, end) is slice(start, end), and slice additionally accepts negative indices counting from the end. There is no StringBuilder; engines optimise += internally, and the array-plus-join idiom is the explicit version.
Raw strings and regex
A template literal spans lines like a raw string, and a regular expression is a literal with its own syntax rather than a class taking a string.
fun main() { val report = """ Sales report Q1: 100 """.trimIndent() println(report) val text = "order 42 shipped" val match = Regex("""order (\d+)""").find(text) println(match?.groupValues?.get(1)) }
const report = `Sales report Q1: 100`; console.log(report); const text = "order 42 shipped"; const match = text.match(/order (\d+)/); console.log(match[1]);
What a template literal cannot do is trimIndent(): it keeps whatever leading whitespace the source had, which is why dedent libraries exist and why the example above is left-aligned. The regex engine is backtracking, so it has lookaround and backreferences and no linear-time guarantee. Flags go after the closing slash — g, i, u, s, m, y — and a g regex object carries mutable state in lastIndex, so reusing one across test calls gives alternating answers.
Collections
List, MutableList and one Array
JavaScript has one array type. The read-only/mutable split that shapes every Kotlin collection signature does not exist, and neither does bounds checking.
fun main() { val readOnly: List<Int> = listOf(1, 2, 3) // readOnly.add(4) // uncomment: unresolved reference — no add on List val numbers = mutableListOf(1, 2, 3) numbers.add(4) println("${numbers.size} ${numbers[2]}") println(numbers.getOrNull(10)) }
const numbers = [1, 2, 3]; numbers.push(4); // every array is mutable console.log(numbers.length, numbers[2]); console.log(numbers[10]); // undefined — no bounds check, no exception numbers[10] = 99; // legal: the array grows, with holes
Reading past the end gives undefined rather than IndexOutOfBoundsException, and writing past the end extends the array with holes — a sparse array whose missing slots are skipped by forEach but counted by length. Avoid that entirely. Since there is no List interface to return, the convention for "do not modify this" is documentation, a copy ([...items]), or Object.freeze, which is shallow and silent outside strict mode.
Map, and the plain object it replaces
There are two mapping types and the newer one is what a Kotlin Map should become. A plain object {} was the only choice for years and is still everywhere.
fun main() { val ages = mutableMapOf("ada" to 36) ages["grace"] = 45 println(ages["ada"]) println(ages["nobody"]) println(ages.getOrDefault("nobody", 0)) for ((name, age) in ages.toSortedMap()) println("$name $age") }
const ages = new Map([["ada", 36]]); ages.set("grace", 45); console.log(ages.get("ada")); console.log(ages.get("nobody")); // undefined, not null console.log(ages.get("nobody") ?? 0); for (const [name, age] of [...ages].sort()) console.log(name, age);
Map takes any value as a key (objects compared by identity), preserves insertion order, has a real size, and has no inherited keys to collide with. A plain object coerces every key to a string — so 1 and "1" are one entry — and carries prototype properties, which is why Object.create(null) exists. Neither has getOrPut, mapValues or toSortedMap: those become explicit code, and iterating a Map in key order means spreading and sorting.
map, filter and the operations that are missing
The core three are there with the same names; the long tail of Kotlin's standard library is not.
fun main() { val numbers = listOf(1, 2, 3, 4, 5, 6) val total = numbers.filter { it % 2 == 0 }.map { it * it }.sum() println(total) println(numbers.groupBy { it % 3 }.toSortedMap().values) println(numbers.chunked(2).first()) }
const numbers = [1, 2, 3, 4, 5, 6]; const total = numbers .filter((number) => number % 2 === 0) .map((number) => number * number) .reduce((running, number) => running + number, 0); console.log(total); const grouped = Object.groupBy(numbers, (number) => number % 3); console.log(Object.keys(grouped).sort().map((key) => grouped[key])); console.log(numbers.slice(0, 2));
sum, chunked, zip, windowed, associateBy, partition, distinctBy, sumOf, maxByOrNull — none of those exist, and each becomes a reduce, a slice loop or a small helper. Object.groupBy and Map.groupBy (2024) are the one recent addition that closes a real gap. Note there is no it: every lambda parameter gets a name, which makes chains longer than Kotlin's.
Sequences become generators, and array methods are eager
A Kotlin List chain is eager and a Sequence opts into laziness. JavaScript array methods are always eager — each step builds a whole new array — and the lazy tool is a generator function.
fun main() { val firstTen = generateSequence(Pair(0, 1)) { (current, next) -> Pair(next, current + next) } .map { it.first } .take(10) .toList() println(firstTen) }
function* fibonacci() { let [current, next] = [0, 1]; while (true) { yield current; [current, next] = [next, current + next]; } } const firstTen = []; for (const value of fibonacci()) { if (firstTen.length === 10) break; firstTen.push(value); } console.log(firstTen.join(","));
So a three-step chain over a million elements makes two intermediate arrays where a Sequence would make none. Generators (function*, yield) are the lazy option and work with for...of and spreading — but they are not composable: there is no .take(10) or .map() on a generator, which is why the example counts by hand. The Iterator Helpers proposal adds exactly those and has shipped in current Node and Chrome.
Sorting compares strings by default
This is the most famous footgun in the standard library, and a Kotlin reader walks straight into it: sort() with no comparator converts every element to a string and sorts lexicographically.
fun main() { val numbers = listOf(10, 9, 100) println(numbers.sorted()) data class Person(val name: String, val age: Int) val people = listOf(Person("Ada", 36), Person("Bob", 25)) println(people.sortedBy { it.age }.map { it.name }) }
const numbers = [10, 9, 100]; console.log([...numbers].sort().join(",")); // "10,100,9" — stringified! console.log([...numbers].sort((a, b) => a - b).join(",")); const people = [{ name: "Ada", age: 36 }, { name: "Bob", age: 25 }]; console.log(people.toSorted((left, right) => left.age - right.age).map((person) => person.name).join(","));
So [10, 9, 100].sort() gives [10, 100, 9]. Always pass a comparator for numbers; it returns a negative number, zero or a positive one, which is compareTo's contract without the type. sortedBy { } becomes a comparator over the key. And sort mutates and returns the same array, unlike Kotlin's sorted()toSorted() (ES2023) is the non-mutating version, along with toReversed, toSpliced and with.
Functions & Lambdas
Default values yes, named arguments no
Default parameter values carry over. Named arguments do not, and their absence is why the options object exists.
fun connect(host: String, port: Int = 5432, timeout: Int = 30) { println("$host:$port timeout=$timeout") } fun main() { connect("db.example.com") connect("db.example.com", timeout = 5) }
function connect(host, { port = 5432, timeout = 30 } = {}) { console.log(`${host}:${port} timeout=${timeout}`); } connect("db.example.com"); connect("db.example.com", { timeout: 5 });
Skipping a middle parameter is impossible positionally, so anything with more than two optional values takes a single object and destructures it in the parameter list — with = {} at the end so calling with no argument still works. That idiom is the JavaScript equivalent of named arguments and is worth adopting deliberately rather than discovering. Note also that extra arguments are silently ignored and missing ones become undefined: arity is never checked.
Lambdas, without it and without trailing syntax
Lambdas are ordinary values in both languages, and two Kotlin conveniences are missing: the implicit it, and trailing-lambda syntax.
fun main() { val numbers = listOf(1, 2, 3) println(numbers.map { it * 2 }.joinToString(",")) val apply: (Int, (Int) -> Int) -> Int = { value, transform -> transform(value) } println(apply(21) { it * 2 }) }
const numbers = [1, 2, 3]; console.log(numbers.map((number) => number * 2).join(",")); const apply = (value, transform) => transform(value); console.log(apply(21, (value) => value * 2));
Every parameter must be named, and every lambda sits inside the parentheses — so the DSL-style html { body { ... } } that trailing lambdas make possible has no direct spelling. A JavaScript function is also an ordinary object with properties: length is its declared arity and name is its name. There is no distinction between a lambda and a function reference, and no inline, so every callback is a real allocation and a real call — which is why hot loops in JavaScript sometimes avoid them.
Closures capture variables, not values
Both languages close over the variable itself, so the counter keeps counting after the enclosing function has returned. This is one of the closest correspondences on the page.
fun makeCounter(): () -> Int { var count = 0 return { ++count } } fun main() { val counter = makeCounter() println("${counter()} ${counter()} ${counter()}") }
function makeCounter() { let count = 0; return () => ++count; } const counter = makeCounter(); console.log(counter(), counter(), counter());
The mechanism differs underneath — Kotlin boxes the captured var in a Ref object, JavaScript keeps the whole scope alive — and the consequence is the same: a closure held by an event handler keeps everything it captured alive, which is the shape of most JavaScript memory leaks. Watch one trap that Kotlin does not have: capturing the loop variable of a C-style for captures the single shared variable, so use let (which gets a fresh binding per iteration) rather than var.
this is decided by the call site
A Kotlin method reference is bound to its receiver. A JavaScript method pulled out of its object is a plain function, and this comes from how it is called rather than from where it was defined.
class Counter { private var count = 0 fun increment(): Int { count += 1 return count } } fun main() { val counter = Counter() val method = counter::increment // a bound reference println(method()) }
class Counter { count = 0; increment() { this.count += 1; return this.count; } } const counter = new Counter(); console.log(counter.increment()); const loose = counter.increment; // just the function, no receiver try { loose(); } catch (error) { console.log(error.constructor.name); } console.log(counter.increment.bind(counter)());
So passing counter.increment as a callback gives a function whose this is undefined, and the failure appears wherever the callback eventually runs. The fixes are bind, an arrow wrapper (() => counter.increment()), or defining the method as a class field holding an arrow function, which captures this lexically. An arrow function has no this of its own at all, which is exactly why it became the default for callbacks.
Varargs and spread
Both languages collect extra arguments into a sequence and spread a sequence into a call, with ... in place of vararg and *.
fun total(vararg numbers: Int): Int = numbers.sum() fun main() { println(total(1, 2, 3)) val values = intArrayOf(4, 5) println(total(*values)) }
function total(...numbers) { return numbers.reduce((running, number) => running + number, 0); } console.log(total(1, 2, 3)); const values = [4, 5]; console.log(total(...values));
The JavaScript rest parameter gives you a real array — not Kotlin's Array<out T> with its primitive-array complications — and the spread operator works in array literals ([...first, ...second]) and object literals ({ ...defaults, ...overrides }) as well as in calls. That object spread is the everyday replacement for a data class copy(), which the classes section returns to.
Classes & Data Classes
A class, without a primary constructor
There is no primary constructor and no property declaration in the header: a constructor body assigns every field, which is the ceremony Kotlin removed.
class Account(val owner: String, private var balance: Int = 0) { fun deposit(amount: Int) { balance += amount } fun total(): Int = balance } fun main() { val account = Account("Ada") account.deposit(100) println("${account.owner} ${account.total()}") }
class Account { #balance; constructor(owner, balance = 0) { this.owner = owner; this.#balance = balance; } deposit(amount) { this.#balance += amount; } total() { return this.#balance; } } const account = new Account("Ada"); account.deposit(100); console.log(account.owner, account.total());
this. is mandatory, and a property is created by assigning to it — there is no declaration to misspell against. Private fields exist now, marked with #, and are genuinely private (unlike the older underscore convention); reading one from outside is a syntax error rather than undefined. What has no counterpart is the property syntax: a Kotlin val with a custom getter becomes get area() { ... }, and there are no init blocks, no secondary constructors and no internal visibility.
A data class becomes an object literal
Most JavaScript data is a plain object literal — no class, no declaration, no type. That is where a data class lands, and three of its four generated members land nowhere.
data class Point(val x: Int, val y: Int) fun main() { val first = Point(1, 2) val second = Point(1, 2) println(first) println(first == second) println(first.copy(y = 99)) }
const first = { x: 1, y: 2 }; const second = { x: 1, y: 2 }; console.log(JSON.stringify(first)); console.log(first === second); // identity, not contents console.log(JSON.stringify({ ...first, y: 99 })); // the copy() equivalent
There is no equals, so === compares identity and two identical objects are unequal; there is no toString, so console.log prints an engine-chosen rendering; and there is no hashCode, because a Map keys objects by identity anyway. For an equality test the usual workaround is JSON.stringify(first) === JSON.stringify(second), which is wrong for key order and for anything JSON cannot represent. What does survive, and survives well, is copy(): { ...first, y: 99 } is the object spread, and it is shallow exactly as copy() is. For deep equality you need a library or node:assert.
object and companion object
A singleton is just an object literal — the language has had them since the beginning, and no keyword is needed to say "there is exactly one of these".
object Registry { private val items = mutableListOf<String>() fun add(item: String) { items.add(item) } fun size(): Int = items.size } class User(val name: String) { companion object { fun guest(): User = User("guest") } } fun main() { Registry.add("first") println(Registry.size()) println(User.guest().name) }
const Registry = { items: [], add(item) { this.items.push(item); }, size() { return this.items.length; }, }; class User { constructor(name) { this.name = name; } static guest() { return new User("guest"); } } Registry.add("first"); console.log(Registry.size()); console.log(User.guest().name);
A companion object becomes static members on the class, which is closer to Java than to Kotlin: they are reached through the class name and cannot implement an interface. There is no object : SomeInterface { } expression either, because there is no interface to declare conformance to — an object literal with the right methods is accepted by anything that calls them, which is the next section's subject.
Interfaces become a shape and a hope
There is no interface, no override, and nothing to declare conformance to. A value is acceptable to a function if it happens to have what that function calls, checked at the instant of the call.
interface Speaker { fun speak(): String fun greet(): String = "hello, ${speak()}" } class Dog : Speaker { override fun speak(): String = "Woof" } fun main() { val speakers: List<Speaker> = listOf(Dog()) for (speaker in speakers) println(speaker.greet()) }
const dog = { speak() { return "Woof"; }, greet() { return `hello, ${this.speak()}`; }, }; for (const speaker of [dog]) console.log(speaker.greet());
Duck typing removes both the ceremony and the guarantee: nothing warns you when an object is missing speak, and nothing tells you which objects are meant to have it. Default interface methods have no home, so shared behaviour goes on a base class, into a mixin (Object.assign(Target.prototype, behaviour)), or into a plain function taking the object. TypeScript restores interfaces — structurally, so a matching object literal satisfies one without saying so.
Inheritance is open, and everything is virtual
Kotlin closes classes by default and makes you write open and override. JavaScript does neither: every class is extendable and every method is replaceable.
open class Animal { open fun speak(): String = "..." } class Dog : Animal() { override fun speak(): String = "Woof" } fun main() { for (animal in listOf(Animal(), Dog())) println(animal.speak()) }
class Animal { speak() { return "..."; } } class Dog extends Animal { speak() { return "Woof"; } } for (const animal of [new Animal(), new Dog()]) console.log(animal.speak());
So a misspelled method name in a subclass silently becomes a new method rather than an error — exactly the bug override exists to prevent. super.speak() works as expected. Underneath there is no vtable: method lookup walks a chain of prototype objects at run time, so a method added to Animal.prototype after the objects exist is immediately available on all of them. That is monkey-patching, and it is the mechanism the extensions section compares against.
Sealed Classes & when
A sealed hierarchy becomes a tag and a convention
The closed hierarchy has no counterpart, so the pattern is a plain object carrying a kind (or type) property that you switch on. It is a convention held together by discipline.
sealed class Shape { data class Circle(val radius: Double) : Shape() data class Square(val side: Double) : Shape() } fun area(shape: Shape): Double = when (shape) { is Shape.Circle -> 3.14 * shape.radius * shape.radius is Shape.Square -> shape.side * shape.side } fun main() { println("${area(Shape.Circle(1.0))} ${area(Shape.Square(2.0))}") }
const circle = { kind: "circle", radius: 1 }; const square = { kind: "square", side: 2 }; function area(shape) { switch (shape.kind) { case "circle": return 3.14 * shape.radius * shape.radius; case "square": return shape.side * shape.side; default: throw new Error(`unknown shape: ${shape.kind}`); } } console.log(area(circle), area(square));
Nothing stops a third kind appearing, nothing checks that the payload matches the tag, and — the part that hurts most — nothing warns when a switch misses a case. That is why the default throws: it is the manual replacement for exhaustiveness, and it fires at run time rather than at compile time. TypeScript turns this exact pattern into a checked discriminated union with a never assertion in the default branch, which is much of why Kotlin developers writing web code reach for it.
when is an expression; switch is a statement
when without a subject is a condition ladder that produces a value. switch compares one value with ===, falls through unless you break, is a statement rather than an expression, and cannot express a range at all.
fun describe(code: Int): String = when { code in 200..201 -> "ok" code in 400..499 -> "client error" code == 500 -> "server error" else -> "something else" } fun main() { println(describe(201)) println(describe(404)) println(describe(302)) }
function describe(code) { if (code >= 200 && code <= 201) return "ok"; if (code >= 400 && code <= 499) return "client error"; if (code === 500) return "server error"; return "something else"; } console.log(describe(201)); console.log(describe(404)); console.log(describe(302));
So the direct translation is an if/return chain, which is what idiomatic JavaScript writes here anyway. The switch (true) { case code >= 400: ... } trick is real and common and reads worse. For a subject-based when over constants, switch is a fair match — remember that every arm needs return or break, since fall-through is the default rather than an opt-in.
Enums become frozen objects
There is no enum. The idiom is a frozen object of constants — and if the cases need data or behaviour, an object of objects, as here.
enum class Status(val label: String) { ACTIVE("still here"), RETIRED("gone"); } fun main() { println(Status.ACTIVE.label) println(Status.valueOf("RETIRED").name) println(Status.entries.size) }
const Status = Object.freeze({ ACTIVE: { name: "ACTIVE", label: "still here" }, RETIRED: { name: "RETIRED", label: "gone" }, }); console.log(Status.ACTIVE.label); console.log(Status.RETIRED.name); console.log(Object.keys(Status).length);
The pieces you lose are the type (a parameter cannot be declared to accept only a Status), valueOf's validation, and entries. Object.freeze is shallow and, outside strict mode, silently ignores writes rather than throwing. The lighter version — const Status = { ACTIVE: "active" } with plain strings — is more common and interoperates better with JSON, at the cost of every comparison being a string comparison.
Result becomes a tagged object or an exception
There is no Result, no runCatching and no getOrElse. Failure is either thrown or encoded in the return value by convention.
fun parsePort(text: String): Result<Int> = runCatching { text.toInt() } fun main() { println(parsePort("8080").getOrElse { -1 }) println(parsePort("http").getOrElse { -1 }) parsePort("http").onFailure { println("failed: ${it::class.simpleName}") } }
function parsePort(text) { const value = Number.parseInt(text, 10); return Number.isNaN(value) ? { ok: false, error: "NumberFormatException" } : { ok: true, value }; } console.log(parsePort("8080").value ?? -1); console.log(parsePort("http").value ?? -1); const failed = parsePort("http"); if (!failed.ok) console.log(`failed: ${failed.error}`);
The tagged-object shape above is the convention, and it has the same drawback as every discriminated union here: nothing checks that you looked at ok before reading value. The alternative is to throw, which is what the standard library does everywhere — and unlike Kotlin, any expression can throw, including a property access on undefined, so a try block is about a region of code rather than about a specific call.
Extensions & Prototypes
Extension functions against touching the prototype
The want is the same; the mechanisms could hardly be more different. A Kotlin extension is a static function resolved at compile time and scoped to wherever it is imported.
fun String.shouted(): String = uppercase() + "!" fun main() { println("hello".shouted()) }
// The direct translation, and the one not to ship: String.prototype.shouted = function () { return this.toUpperCase() + "!"; }; console.log("hello".shouted()); // What to write instead — a plain function: const shouted = (text) => text.toUpperCase() + "!"; console.log(shouted("hello"));
Assigning to String.prototype changes the string type globally and permanently for every script on the page, which is how libraries used to collide with each other and with future standards — the reason Array.prototype.flatten had to be renamed flat. Do not do it. The idiomatic replacement is a plain function taking the value as its first argument, which is exactly what Kotlin compiles your extension into anyway; the difference is only that the call reads shouted(text) rather than text.shouted().
Extension properties and infix functions
Extension properties and infix notation are the two features that make Kotlin DSLs read the way they do, and neither has any counterpart.
val String.initials: String get() = split(" ").mapNotNull { it.firstOrNull() }.joinToString("") infix fun Int.times(action: (Int) -> Unit) { repeat(this) { action(it) } } fun main() { println("Ada Lovelace".initials) 2 times { println("tick $it") } }
const initials = (fullName) => fullName.split(" ").map((part) => part[0] ?? "").join(""); const times = (count, action) => { for (let index = 0; index < count; index++) action(index); }; console.log(initials("Ada Lovelace")); times(2, (index) => console.log(`tick ${index}`));
A computed property on a type you do not own would mean Object.defineProperty on a built-in prototype — the same global mutation as the previous row, plus a getter. Infix calls simply do not exist: there is no way to define an operator or to call a two-argument function without parentheses, and operator overloading is absent too, so + on your own type is impossible. What JavaScript offers in exchange is that a plain function is always available and never conflicts with anyone.
The one hook the platform does give you
You cannot add a method to someone else's type, but you can implement methods the language already looks for — which is the nearest thing to satisfying an interface.
class Money(val cents: Int) { override fun toString(): String = "$" + "${cents / 100}.${(cents % 100).toString().padStart(2, '0')}" } fun main() { println(Money(725)) }
class Money { constructor(cents) { this.cents = cents; } toString() { return `$${Math.trunc(this.cents / 100)}.${String(this.cents % 100).padStart(2, "0")}`; } toJSON() { return { cents: this.cents }; } [Symbol.iterator]() { return [this.cents][Symbol.iterator](); } } const money = new Money(725); console.log(`${money}`); console.log(JSON.stringify({ price: money })); console.log([...money]);
toString is called by string interpolation and by +; toJSON is consulted by JSON.stringify, which is as close as this gets to a serialiser; Symbol.iterator makes a value work with for...of and spreading, which is Iterable. Note that console.log of an object ignores toString and prints its own rendering, so a value looks different depending on whether you logged it or interpolated it — a real source of confusion when debugging.
Coroutines Against Promises
suspend against async: only one colours the caller
Both keywords mark a compiler transform into a state machine, and both require the caller to be inside one. The colouring problem is the same problem in both languages.
import kotlinx.coroutines.* suspend fun fetch(id: Int): String { delay(1) return "record $id" } fun main() = runBlocking { println(fetch(1)) }
(async () => { const fetch = async (id) => { await new Promise((resolve) => setTimeout(resolve, 1)); return `record ${id}`; }; console.log(await fetch(1)); })();
The differences are what happens at the edges. A suspend function can only be called from a coroutine, and runBlocking is the bridge from ordinary code; in JavaScript the bridge is an async IIFE, or top-level await in a module (not available under node -e, which is why every async example here is wrapped). And a JavaScript async function starts immediately when called, returning a promise for work already in flight, whereas a suspend function does nothing until it is called from a coroutine — there is no cold-versus-hot distinction to manage here.
awaitAll against Promise.all
Starting several operations and waiting for all of them is one call in both languages, and the failure behaviour is the part worth checking.
import kotlinx.coroutines.* suspend fun work(number: Int): Int { delay(1) return number * 10 } fun main() = runBlocking { val results = (1..3).map { async { work(it) } }.awaitAll() println(results) }
(async () => { const work = async (number) => { await new Promise((resolve) => setTimeout(resolve, 1)); return number * 10; }; const results = await Promise.all([1, 2, 3].map(work)); console.log(results.join(",")); const settled = await Promise.allSettled([Promise.reject(new Error("no"))]); console.log(settled[0].status); })();
Promise.all rejects as soon as any input rejects — like awaitAll — but the others keep running with their results discarded, because there is no scope to cancel them. Promise.allSettled collects every outcome and never rejects, which is usually what a batch wants; Promise.race settles with the first outcome of any kind and Promise.any with the first success. None of them is parallelism: there is one thread, so only I/O overlaps.
A scope that cancels its children — and its absence
This is the row the page exists for. Kotlin's coroutineScope owns its children: when one fails, the others are cancelled and the scope does not return until every child has finished. JavaScript has no such thing.
import kotlinx.coroutines.* fun main() = runBlocking { try { coroutineScope { launch { delay(50) println("this never prints") } launch { throw IllegalStateException("one child failed") } } } catch (error: IllegalStateException) { println("scope failed: ${error.message}") } println("and the sibling was cancelled with it") }
(async () => { const slow = (async () => { await new Promise((resolve) => setTimeout(resolve, 50)); console.log("this DOES print — nobody cancelled it"); })(); const failing = (async () => { throw new Error("one child failed"); })(); try { await Promise.all([slow, failing]); } catch (error) { console.log(`scope failed: ${error.message}`); } await slow; })();
The JavaScript column prints its "never" line, because a promise nobody is waiting for keeps running regardless — there is no Job tree, no parent, and no cancellation at all. AbortController is the by-convention replacement and works only for APIs that accept a signal and check it. Two practical consequences: work can be orphaned silently, and an unawaited promise that rejects becomes an unhandled rejection, which terminates a Node process and logs a warning in a browser.
Cancellation, and what stands in for it
Cancelling a Kotlin job is one call, and every suspension point inside it becomes a cancellation point automatically. The JavaScript version is a flag you have to check yourself.
import kotlinx.coroutines.* fun main() = runBlocking { val job = launch { repeat(10) { step -> delay(30) println("step $step") } } delay(45) job.cancelAndJoin() println("cancelled") }
(async () => { const controller = new AbortController(); const work = async (signal) => { for (let step = 0; step < 10; step++) { await new Promise((resolve) => setTimeout(resolve, 30)); if (signal.aborted) return; // cooperative, and manual console.log(`step ${step}`); } }; const running = work(controller.signal); await new Promise((resolve) => setTimeout(resolve, 45)); controller.abort(); await running; console.log("cancelled"); })();
AbortController is the platform convention: pass its signal down, and either check signal.aborted at each step or hand the signal to an API that honours it (fetch does; setTimeout does not). Nothing enforces the checking, so a loop that never looks at the signal simply runs to completion. There is no withTimeout either — a timeout is Promise.race against a timer, and the loser keeps running.
One thread, and no dispatchers
There are no threads in the language, so Dispatchers.Default, Dispatchers.IO and withContext have nothing to switch between. One call stack, one heap, one event loop.
import kotlinx.coroutines.* fun main() = runBlocking { val total = withContext(Dispatchers.Default) { (0 until 5_000_000L).sum() } println(total) println("computed on a worker thread, off the caller's") }
let total = 0; for (let value = 0; value < 5_000_000; value++) total += value; console.log(total); console.log("nothing else could run during that loop");
A tight loop occupies all of it: in a browser the page stops responding, and in Node every other request waits. The escape hatches are Web Workers and Node's worker_threads, which are separate JavaScript realms communicating by message passing — structured-cloned copies, not shared references, so there is nothing like a shared mutable object between them and correspondingly nothing like a data race. That also means @Synchronized, Mutex and atomics have no counterpart, because no two lines of your code ever run at the same instant.
Flow becomes an async generator
A cold stream of values produced over time is an async generator, consumed with for await...of. The shape matches Flow closely — cold, one consumer at a time, backpressure by construction.
import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun ticks(): Flow<Int> = flow { for (step in 1..3) { delay(1) emit(step) } } fun main() = runBlocking { ticks().map { it * 10 }.collect { println(it) } }
(async () => { async function* ticks() { for (let step = 1; step <= 3; step++) { await new Promise((resolve) => setTimeout(resolve, 1)); yield step; } } for await (const value of ticks()) { console.log(value * 10); } })();
What is missing is the operator library: no map, filter, debounce, flatMapLatest, buffer or conflate on an async generator, so the transformation happens in the loop body or in a helper you write. There is no StateFlow or SharedFlow either — a hot, multi-subscriber stream means an EventTarget, a callback list, or a library such as RxJS, which is where a good deal of the Flow vocabulary originally came from.
Errors
try / catch / finally
The structure survives; the typed catch does not. JavaScript has one catch block, and selecting by type is an instanceof ladder inside it.
class InsufficientFunds(val shortfall: Int) : RuntimeException("short by $shortfall") fun main() { try { throw InsufficientFunds(25) } catch (error: InsufficientFunds) { println("${error.message} ${error.shortfall}") } finally { println("always runs") } }
class InsufficientFunds extends Error { constructor(shortfall) { super(`short by ${shortfall}`); this.name = "InsufficientFunds"; this.shortfall = shortfall; } } try { throw new InsufficientFunds(25); } catch (error) { if (error instanceof InsufficientFunds) { console.log(error.message, error.shortfall); } else { throw error; } } finally { console.log("always runs"); }
That matters because anything at all can be thrown — a string, a number, undefined — so a catch block must not assume it received an Error. The this.name assignment is not optional boilerplate: without it the class name does not appear in the message or the stack trace. Also note instanceof fails across realms, so an error from a worker or an iframe is not instanceof your class; library code often checks error.name instead.
try is not an expression
In Kotlin try produces a value, so it can initialise a val. In JavaScript it is a statement, so the variable is declared first and assigned in both branches.
fun main() { val port = try { "http".toInt() } catch (error: NumberFormatException) { -1 } println(port) }
let port; try { port = Number.parseInt("http", 10); if (Number.isNaN(port)) throw new TypeError("not a number"); } catch { port = -1; } console.log(port);
That is why let appears where you would have written val, and it is the most common reason a translated Kotlin function grows a mutable variable it did not have. Note the bare catch { } with no binding — legal since ES2019 when you do not need the error. Note too that parseInt returns NaN rather than throwing, which is the JavaScript pattern in miniature: failure as a value that keeps flowing.
An unhandled rejection is a different kind of crash
A failure inside a coroutine propagates to its scope, where something is responsible for it — a parent that cancels its siblings, or a supervisor that does not. A failure inside a promise nobody is handling goes to a process-wide hook instead.
import kotlinx.coroutines.* fun main() = runBlocking { supervisorScope { val deferred = async { throw IllegalStateException("boom") } try { deferred.await() } catch (error: IllegalStateException) { println("caught: ${error.message}") } } println("the parent scope survived") }
(async () => { const failing = (async () => { throw new Error("boom"); })(); failing.catch((error) => console.log(`caught: ${error.message}`)); // Without that .catch, this becomes an "unhandled rejection": // Node terminates the process; a browser logs a warning and carries on. await failing.catch(() => {}); console.log("still here"); })();
The consequences differ by host and are worth knowing before the first production incident: Node terminates the process on an unhandled rejection (since v15), while a browser logs to the console and keeps going. Attaching a .catch later than the same tick is already too late in Node. The habit to build is that every promise either gets awaited inside a try or gets a .catch immediately — there is no supervisor to fall back on.
Modules, Gradle & npm
One module per file, and two module systems
A file is a module and the filesystem path in the import is the path — there is no package declaration and no directory convention to satisfy.
package geometry fun area(width: Double, height: Double): Double = width * height fun main() { println(area(3.0, 4.0)) }
// geometry.js: export function area(width, height) { return width * height; } // main.js: import { area } from "./geometry.js"; console.log(area(3, 4));
Everything is private until exported, which matches Kotlin's internal/public more closely than its package visibility. The complication is that there are two systems in the wild: ESM (import/export, the standard, statically analysable) and CommonJS (require/module.exports, Node's original, dynamic and synchronous). A package declares which it is in package.json, and interop between them is the most tedious part of Node work.
Gradle against npm
The package manager is the familiar half. The unfamiliar half is that it is only a package manager.
// build.gradle.kts declares dependencies, tasks, source sets and targets. // ./gradlew build / test / run // One tool: build, test, dependencies, packaging, multiplatform targets. fun main() { println("one tool, one build file, one lockfile if you enable it") }
// npm install lodash → node_modules/, thousands of packages // npm run build → whichever bundler you chose // test: vitest or jest; lint: eslint; format: prettier; types: tsc console.log("one tool for packages, and a separate one for everything else");
npm installs and runs scripts; it does not build, test, format, lint or generate. Each of those is a separate dependency you choose and configure, which is why a new JavaScript project starts with more decisions than a new Gradle one — and why the JavaScript half of a Kotlin Multiplatform project brings a second toolchain with it. Dependency trees are an order of magnitude larger. package-lock.json is committed, and npm ci is the CI command that honours it exactly.
Tests live in a separate file
The layout convention is the same idea — tests beside the code but not in it — with a different shape.
fun double(value: Int): Int = value * 2 fun main() { // In a real project this is src/test/kotlin/DoubleTest.kt: // @Test fun doubles() { assertEquals(42, double(21)) } println(double(21)) }
const assert = require("node:assert"); function double(value) { return value * 2; } // In a real project this is double.test.js, run by `node --test`, // vitest or jest — never in the shipped module. assert.strictEqual(double(21), 42); console.log(double(21));
Kotlin separates by source set (src/test/kotlin); JavaScript separates by filename convention (name.test.js) and by the bundler excluding it. Node has had a built-in runner since 18 (node --test plus node:assert), which is the closest thing to running ./gradlew test; most projects still use vitest or jest for watch mode, mocking and a browser environment. There is no @Test annotation because there are no annotations — the runner finds tests by filename and by the global test/it functions it injects.
Kotlin/JS & Multiplatform
What @JsExport puts on the other side
The reason most readers are here: a Kotlin module compiled to JavaScript, consumed by JavaScript code. @JsExport is what decides which declarations are visible on the other side.
// build.gradle.kts: kotlin { js(IR) { browser(); binaries.executable() } } // @JsExport ← on a JS target. The JVM compiler this page's suite uses does // not know the annotation, so it is commented out here. class Greeter(private val name: String) { fun greet(): String = "Hello, $name!" } fun main() { println(Greeter("Ada").greet()) }
// import { Greeter } from "./shared.js"; // the Kotlin/JS output // const greeter = new Greeter("Ada"); // console.log(greeter.greet()); console.log("the JS side sees an ordinary class with ordinary methods");
Without it, the IR compiler mangles names and may eliminate anything unreachable from Kotlin — so an unexported class simply is not there. @JsName("greet") pins a specific name when mangling would otherwise change it. Not every Kotlin type can be exported: suspend functions, Long, non-external interfaces and generics with unusual bounds are all restricted, and the compiler tells you at build time. Design the exported surface as a small, boring API — primitives, strings, arrays and plain classes.
dynamic is the escape hatch back
Interoperating in the other direction — Kotlin calling a JavaScript library — needs a way to talk about values the compiler knows nothing about.
// Kotlin/JS only — the 'dynamic' type turns type checking off for one value. // val response: dynamic = js("({ id: 1, tags: ['new'] })") // println(response.id) // no checking, resolved at run time // println(response.tags.length) // external declarations describe a JS library's shape to the compiler: // external fun require(module: String): dynamic fun main() { println("dynamic is Kotlin/JS's any, and external is its .d.ts") }
const response = { id: 1, tags: ["new"] }; console.log(response.id); console.log(response.tags.length); console.log("everything here is what dynamic describes");
dynamic is that hatch: member access on it is resolved at run time exactly as JavaScript resolves everything, with no checking and no completion. The disciplined alternative is an external declaration describing the library's shape, which is Kotlin's equivalent of a TypeScript .d.ts file — and Dukat could generate them from one. Keep dynamic at the boundary and convert to real Kotlin types immediately, for the same reason you would validate JSON at the edge.
What actually crosses in a Multiplatform build
The practical shape of a Kotlin Multiplatform web target, and the two things worth knowing before designing the shared module.
// commonMain: pure Kotlin, no platform APIs. // expect fun platformName(): String // jsMain: // actual fun platformName(): String = "JS" // androidMain: // actual fun platformName(): String = "Android" fun main() { println("expect/actual is how one API gets two implementations") }
// The JS target produces an ES module (or CommonJS, or UMD) plus a // .d.ts if you ask for it. From the JavaScript side it is an ordinary // dependency — no runtime to install, though the Kotlin stdlib is bundled in. console.log("the shared module arrives as an ordinary npm-shaped package");
First, the Kotlin standard library travels with the output, so the bundle is larger than the equivalent hand-written JavaScript — measure it early rather than at the end. Second, expect/actual is how one shared API gets a JavaScript implementation and an Android one, and it is the right place to put anything that touches the platform: storage, HTTP, dates, randomness. Keep commonMain free of platform assumptions and the JavaScript target mostly takes care of itself.

Thank you — anything else?