PONY λ M2 Modula-2

Swift.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing Swift and Kotlin

Swift 6.3 Kotlin 2.3
Basics & Syntax
Hello, World
print("Hello, World!")
fun main() { println("Hello, World!") }
Kotlin needs an entry point — fun main() — where Swift lets top-level statements be the program. Beyond that this is the same language wearing different keywords: func is fun, print is println, semicolons are optional in both, and type inference does most of the work.
let and var become val and var
let fixed = 1 var counter = 0 counter += 1 let explicit: Int64 = 42 let name = "Ada" // String interpolation with a backslash. print("\(name): \(fixed + counter), \(explicit)") // Multi-line strings. let letter = """ Dear \(name), Regards """ print(letter)
fun main() { val fixed = 1 // let var counter = 0 // var counter += 1 val explicit: Long = 42 val name = "Ada" // Interpolation with a dollar sign, and ${...} for expressions. println("$name: ${fixed + counter}, $explicit") // Raw strings, plus trimIndent() to strip the leading whitespace. val letter = """ Dear $name, Regards """.trimIndent() println(letter) }
let is val, and the immutable-by-default habit carries straight over. The only adjustments are cosmetic: interpolation is $name and ${expression} rather than \(…), and a raw string needs an explicit .trimIndent() where Swift strips the indentation based on the closing delimiter.
Optionals & Null Safety
Optional<T> becomes T?
The feature both languages are proudest of, and they built it the same way — with one real difference in how the unwrapped value is bound.
func findName(_ id: Int) -> String? { id == 1 ? "Ada" : nil } print(findName(1)?.uppercased() ?? "(none)") // optional chaining, nil-coalescing print(findName(1)!) // force unwrap // if let creates a NEW binding, and shadowing it is idiomatic. if let name = findName(1) { print(name.count) } // guard let, for the early exit. func shout(_ id: Int) -> String { guard let name = findName(id) else { return "(nobody)" } return name.uppercased() } print(shout(99))
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun shout(id: Int): String { // The '?: return' idiom is guard let. There is no 'guard' keyword — the // Elvis operator's right-hand side may be a return or a throw. val name = findName(id) ?: return "(nobody)" return name.uppercase() } fun main() { println(findName(1)?.uppercase() ?: "(none)") // ?. and ?: (Elvis) println(findName(1)!!) // !! is the force unwrap // SMART CAST: after a null check, the compiler treats the SAME variable as // non-null. There is no new binding, and no 'if let name = name' shadowing. val name = findName(1) if (name != null) { println(name.length) // name is String here, not String? } // ?.let { } is the closest thing to 'if let' when you want a new scope. findName(1)?.let { println(it.length) } println(shout(99)) }
The operators line up one for one — ?. is ?., ?? is ?:, ! is !! — and the difference is in the unwrapping. Swift binds a new value (if let name = name), while Kotlin smart-casts the existing one: after if (name != null), the compiler narrows the type of name itself, so there is no shadowing dance. guard let … else becomes the ?: return idiom, which works because Kotlin's return and throw are expressions of type Nothing. One real gap: smart casts only work on values the compiler knows cannot change between the check and the use, so a mutable class property (var) will not smart-cast — you assign it to a local first, which is the one place Swift's if let is simply better.
The hole in Kotlin's null safety: platform types
Kotlin's null safety is as strong as Swift's — right up to the boundary with Java, where it stops checking entirely.
import Foundation // Swift's equivalent boundary is Objective-C, where an unannotated pointer // becomes an IMPLICITLY UNWRAPPED optional (String!). Modern frameworks are // audited, so in practice you rarely see one — but the hazard is the same: // a value the compiler treats as non-nil that may still be nil at run time. let formatter = DateFormatter() formatter.dateFormat = "yyyy" // Swift's own APIs return real optionals, checked properly. let parsed: Date? = formatter.date(from: "2026") print(parsed != nil)
fun main() { // Kotlin's boundary is JAVA. A Java method has no nullability information // unless it is annotated, so Kotlin gives its result a PLATFORM TYPE // (written String! in error messages, and unwritable by you). // // val name: String = someJavaApi.getName() // NO null check performed // name.length // NullPointerException, at run time // // The compiler simply steps aside: it will let you assign a platform type to // a non-null String with no complaint. This is the ONE hole in Kotlin's null // safety, and it is where NPEs in a Kotlin codebase actually come from. // System.getenv is a real Java API, and it may return null. val home: String? = System.getenv("NOT_SET_ANYWHERE") println(home ?: "(unset)") // The defensive move at any Java boundary: declare the type YOURSELF as // nullable, and let the compiler start checking again. val checked: String? = System.getenv("PATH") println(checked?.isNotEmpty() ?: false) }
Both languages have a hole at the boundary with an older, unannotated language, and both holes work the same way: a value the compiler treats as non-null but which may be null at run time (Kotlin's platform type String!, Swift's implicitly unwrapped String!). The difference is how much of it you meet. Apple audited its frameworks years ago, so Swift developers rarely see one; a Kotlin codebase on Android or the JVM is calling Java constantly, so platform types are everywhere, and they are where NPEs in "null-safe" Kotlin actually come from. The habit to build: at every Java boundary, write the type yourself as T?, and the compiler starts protecting you again.
Value vs Reference Semantics
A data class is not a struct
The most dangerous false friend on this page. A Kotlin data class looks like a Swift struct and is not one: it is a reference type, so assignment shares the object rather than copying it.
struct Point { var x: Int var y: Int } var first = Point(x: 1, y: 2) var second = first // a COPY. Two independent values. second.x = 99 print(first) // Point(x: 1, y: 2) — untouched print(second) // Point(x: 99, y: 2) // Value semantics all the way down: an array of structs copies too. var points = [Point(x: 0, y: 0)] var copied = points copied[0].x = 42 print(points[0].x, copied[0].x) // 0 42
// A data class gives you equals/hashCode/toString/copy — but it is a CLASS. data class Point(var x: Int, var y: Int) fun main() { val first = Point(1, 2) val second = first // NOT a copy: both names point at the SAME object second.x = 99 println(first) // Point(x=99, y=2) — mutated through the other name! println(second) // To copy, you must SAY so. copy() is a generated method, not a semantic. val third = first.copy() third.x = 7 println(first.x) // still 99 — copy() gave us an independent object println(third.x) // And copy() is SHALLOW: a nested data class is still shared. val list = mutableListOf(Point(0, 0)) val alias = list // the list is shared too alias[0].x = 42 println(list[0].x) // 42 // The way to get value semantics is to make it IMMUTABLE: use val, not var. // Then sharing is harmless, and copy(x = ...) is how you "change" it. println(first.copy(x = 1)) }
Kotlin has no value types (a value class exists, but it wraps exactly one property and is a different tool). So every data class is a reference, second = first aliases, and a var property mutated through one name is visible through the other — the exact bug Swift's structs make impossible. Two consequences worth internalizing. copy() is a method you must call, not something the language does for you, and it is shallow — nested objects are still shared. And the way to recover value semantics in Kotlin is to make your data classes immutable (all val, no var), so that sharing cannot hurt you and copy(field = new) is the only way to produce a changed version. That is the idiom to adopt on day one; it is also, not coincidentally, how Swift developers already write structs.
Functions & Lambdas
Named arguments and defaults, without the labels
// Argument labels are part of the function's NAME, and callers must use them. func greet(name: String, salutation: String = "Hello") -> String { "\(salutation), \(name)!" } // _ opts out of the label; a second label renames it at the call site. func move(_ point: (Int, Int), by delta: Int) -> (Int, Int) { (point.0 + delta, point.1 + delta) } print(greet(name: "Ada")) print(greet(name: "Ada", salutation: "Hi")) print(move((1, 2), by: 3))
// Named arguments are OPTIONAL at the call site — the parameter name is the // label, and there is no separate external name. fun greet(name: String, salutation: String = "Hello") = "$salutation, $name!" fun move(point: Pair<Int, Int>, delta: Int) = Pair(point.first + delta, point.second + delta) fun main() { println(greet("Ada")) // positional is fine println(greet("Ada", salutation = "Hi")) // or name it println(greet(salutation = "Hi", name = "Ada")) // any order, once named println(move(Pair(1, 2), delta = 3)) // A single-expression function needs no braces and no return type. println(move(Pair(1, 2), 3)) }
Both languages have named arguments and default values, which puts them together in a small club. The difference is compulsion: a Swift argument label is part of the function's name and the caller must write it (unless you opt out with _), while Kotlin's names are optional at the call site and exist only when you want the clarity. Kotlin also has no separate external name — the parameter name is the label, so by delta: has no equivalent and you name the parameter for the caller's benefit.
Closures, trailing syntax, and it
let numbers = [1, 2, 3, 4] // $0 is the shorthand argument; the trailing closure goes outside the parens. print(numbers.map { $0 * 2 }) print(numbers.filter { $0 % 2 == 0 }) print(numbers.reduce(0, +)) // A closure stored in a variable, with an explicit type. let add: (Int, Int) -> Int = { first, second in first + second } print(add(2, 3)) // A function taking a closure. func apply(_ value: Int, _ transform: (Int) -> Int) -> Int { transform(value) } print(apply(21) { $0 * 2 })
fun apply(value: Int, transform: (Int) -> Int): Int = transform(value) fun main() { val numbers = listOf(1, 2, 3, 4) // 'it' is $0, and the trailing lambda works the same way. println(numbers.map { it * 2 }) println(numbers.filter { it % 2 == 0 }) println(numbers.reduce { total, number -> total + number }) // The parameter list goes INSIDE the braces, before ->. val add: (Int, Int) -> Int = { first, second -> first + second } println(add(2, 3)) println(apply(21) { it * 2 }) // Scope functions have no Swift equivalent and are worth learning early: // let/run/apply/also/with turn an object into the receiver of a block. val message = StringBuilder().apply { append("Hello") append(", Kotlin") }.toString() println(message) }
Nearly identical, down to trailing-lambda syntax: $0 is it, and the closure lives outside the parentheses when it is the last argument. Two things to pick up. The lambda's parameter list goes inside the braces ({ first, second -> … }) rather than being declared before in. And Kotlin's scope functionslet, run, apply, also, with — have no Swift counterpart at all; they are lambdas with a receiver, and apply { } in particular replaces the configure-then-assign dance you would otherwise write.
Structs, Classes & Data Classes
Classes, constructors, and properties
class Rectangle { let width: Double var height: Double // A computed property. var area: Double { width * height } // A property observer. var label: String = "" { didSet { print("label changed to \(label)") } } init(width: Double, height: Double) { self.width = width self.height = height } } let rectangle = Rectangle(width: 3, height: 4) print(rectangle.area) rectangle.height = 10 print(rectangle.area) rectangle.label = "big"
import kotlin.properties.Delegates class Rectangle(val width: Double, var height: Double) { // the primary constructor // A computed property: a getter, no backing field. val area: Double get() = width * height // The property-observer equivalent: a delegate. var label: String by Delegates.observable("") { _, _, new -> println("label changed to $new") } // init { } runs as part of the primary constructor. init { require(width > 0) { "width must be positive" } } } fun main() { val rectangle = Rectangle(3.0, 4.0) println(rectangle.area) rectangle.height = 10.0 println(rectangle.area) rectangle.label = "big" }
The primary constructor is Kotlin's best syntax and Swift's clearest omission: class Rectangle(val width: Double, var height: Double) declares the parameters, the properties, and the initializer in one line, where Swift needs the properties, the init, and the self.x = x assignments spelled out. Computed properties exist in both (val area get() = …). Property observers become delegatesby Delegates.observable is didSet, and by lazy is lazy var — which is a more general mechanism, since you can write your own delegate.
static becomes a companion object
class User { static let defaultName = "anonymous" private static var created = 0 let name: String // A static factory method. static func make(_ name: String) -> User { created += 1 return User(name: name) } static func createdCount() -> Int { created } private init(name: String) { self.name = name } } print(User.make("Ada").name) print(User.make("Alan").name) print(User.createdCount()) print(User.defaultName)
class User private constructor(val name: String) { // A companion object is a real OBJECT (a singleton) attached to the class — // it can implement interfaces and be passed around, which statics cannot. companion object { const val DEFAULT_NAME = "anonymous" private var created = 0 fun make(name: String): User { created += 1 return User(name) } fun createdCount() = created } } // A top-level singleton, with no class to hang it on. object Registry { private var hits = 0 fun record() = ++hits } fun main() { println(User.make("Ada").name) // called exactly like a static println(User.make("Alan").name) println(User.createdCount()) println(User.DEFAULT_NAME) Registry.record() println(Registry.record()) }
The call sites are identical (User.make("Ada")), but the machinery is different in a way that occasionally matters: a companion object is a genuine object — it can implement an interface, be assigned to a variable, and be passed to a function — where Swift's static members are not values. Kotlin also has the object declaration, a language-guaranteed singleton, which replaces the static let shared = … pattern you have written a hundred times.
Enums & Sealed Classes
Enums with associated values become sealed classes
The false friend that will cost you an afternoon. Kotlin has enum class, and it is not where your Swift enums go — a Kotlin enum cannot carry per-case data. Sealed classes are the sum type.
enum Shape { case circle(radius: Double) case rectangle(width: Double, height: Double) case point } func area(_ shape: Shape) -> Double { switch shape { // exhaustive: a missing case will not compile case .circle(let radius): return 3.14159 * radius * radius case .rectangle(let width, let height): return width * height case .point: return 0 } } print(area(.circle(radius: 1))) print(area(.rectangle(width: 2, height: 3))) print(area(.point))
// A sealed hierarchy: the compiler knows the complete set of subtypes, because // they must all live in the same package and module. sealed interface Shape { data class Circle(val radius: Double) : Shape data class Rectangle(val width: Double, val height: Double) : Shape data object Point : Shape // no payload, so a singleton object } // when-as-expression over a sealed type is EXHAUSTIVE: no else needed, and // adding a fourth Shape breaks this at compile time. fun area(shape: Shape): Double = when (shape) { is Shape.Circle -> 3.14159 * shape.radius * shape.radius // smart cast is Shape.Rectangle -> shape.width * shape.height Shape.Point -> 0.0 } fun main() { println(area(Shape.Circle(1.0))) println(area(Shape.Rectangle(2.0, 3.0))) println(area(Shape.Point)) }
A Kotlin enum class is a fixed set of singletons — closer to a Swift enum with a raw value and no associated values. The moment a case needs a payload, you want a sealed interface (or sealed class) with a data class per case, and then when over it is exhaustive exactly like switch. The ergonomic difference: Swift destructures in the pattern (case .circle(let radius)), while Kotlin smart-casts and you reach through the value (shape.radius). Both compilers reject a missing case, which is the guarantee that actually matters.
Simple enums, and the missing raw values
enum Status: String, CaseIterable { case active = "running" case paused = "on hold" var isRunning: Bool { self == .active } } for status in Status.allCases { print(status, status.rawValue, status.isRunning) } // Raw-value initialization is failable, and gives you an Optional. print(Status(rawValue: "running") ?? .paused) print(Status(rawValue: "nonsense") == nil)
enum class Status(val label: String) { ACTIVE("running"), PAUSED("on hold"); val isRunning: Boolean get() = this == ACTIVE companion object { // There is no rawValue initializer. valueOf() matches the NAME (and // throws on a miss), so a lookup by payload is written by hand. fun fromLabel(label: String): Status? = entries.find { it.label == label } } } fun main() { // entries (Kotlin 1.9+) is allCases. values() is the older, allocating form. for (status in Status.entries) { println("$status ${status.label} ${status.isRunning}") } println(Status.fromLabel("running")) println(Status.fromLabel("nonsense")) println(Status.valueOf("ACTIVE")) // by NAME, and it THROWS on a miss }
Kotlin enums carry constructor parameters, properties, and methods, so most of what you do with a Swift enum ports directly — with two gaps. There is no rawValue: valueOf matches the name and throws rather than returning null, so a failable lookup by payload is a hand-written entries.find { }. And allCases is entries (the older values() allocated a fresh array on every call, which is why it was replaced).
Protocols & Interfaces
Protocols become interfaces
protocol Describable { var name: String { get } func describe() -> String } // A protocol extension gives every conformer a default implementation. extension Describable { func describe() -> String { "I am \(name)" } } struct Dog: Describable { let name: String } struct Robot: Describable { let name: String func describe() -> String { "BEEP. I AM \(name.uppercased())" } } let things: [any Describable] = [Dog(name: "Rex"), Robot(name: "R2")] for thing in things { print(thing.describe()) }
interface Describable { val name: String // an abstract property // A default method — Swift's protocol extension, but INSIDE the interface. fun describe(): String = "I am $name" } data class Dog(override val name: String) : Describable data class Robot(override val name: String) : Describable { override fun describe() = "BEEP. I AM ${name.uppercase()}" } fun main() { val things: List<Describable> = listOf(Dog("Rex"), Robot("R2")) for (thing in things) { println(thing.describe()) } }
Close to a rename: an interface can declare properties and provide default method bodies, so a protocol extension becomes a default implementation written inside the interface itself. Two differences. A Kotlin interface cannot hold state — it may declare a property but never store it, which is why override val name appears on every implementer. And override is a mandatory keyword in Kotlin, where Swift infers conformance from the signature; forgetting it is a compile error rather than an accidental new method.
Extensions — with one big restriction
extension String { var initials: String { split(separator: " ").compactMap { $0.first }.map(String.init).joined() } func shout() -> String { uppercased() + "!" } } print("Ada Lovelace".initials) print("hello".shout()) // Swift extensions can add protocol conformance retroactively — a type you do // not own can be made to conform to a protocol you do not own. extension Int: Error {} // And they can add stored... no. Not even Swift can add a stored property.
// Extension functions and extension PROPERTIES both exist. val String.initials: String get() = split(" ").mapNotNull { it.firstOrNull() }.joinToString("") fun String.shout() = uppercase() + "!" fun main() { println("Ada Lovelace".initials) println("hello".shout()) // The restriction: an extension CANNOT add an interface conformance. There // is no way to make an existing type implement an interface after the fact — // Swift's retroactive conformance has no Kotlin equivalent. // // And extensions are dispatched STATICALLY, on the declared type: open class Base class Derived : Base() fun Base.describe() = "base" fun Derived.describe() = "derived" val value: Base = Derived() println(value.describe()) // "base" — the DECLARED type wins, not the actual one }
Both languages have extension methods and extension properties, and neither can add a stored property. The gap that matters is retroactive conformance: Swift can make an existing type conform to an existing protocol from outside (extension Int: Error {}), which is the trick that makes so much of the standard library composable — and Kotlin cannot do it at all. Note also that extensions are dispatched statically in both languages: the last line of the right column prints "base", because the declared type is what gets looked at, not the runtime one.
Generics: reified type parameters
func largest<T: Comparable>(_ items: [T]) -> T? { items.max() } print(largest([3, 1, 4]) ?? 0) print(largest(["b", "a"]) ?? "") // Swift generics are reified: the type is available at run time, so this works. func firstOfType<T>(_ items: [Any], _ type: T.Type) -> T? { items.compactMap { $0 as? T }.first } let mixed: [Any] = [1, "two", 3.5] print(firstOfType(mixed, String.self) ?? "(none)")
fun <T : Comparable<T>> largest(items: List<T>): T? = items.maxOrNull() // The JVM ERASES type parameters — but 'reified' on an INLINE function brings // the type back at run time, which Java cannot do at all. inline fun <reified T> firstOfType(items: List<Any>): T? = items.filterIsInstance<T>().firstOrNull() fun main() { println(largest(listOf(3, 1, 4))) println(largest(listOf("b", "a"))) val mixed: List<Any> = listOf(1, "two", 3.5) println(firstOfType<String>(mixed) ?: "(none)") // Without 'reified', 'is T' would not compile: the type is erased, and the // function would have no idea what T was. }
Kotlin runs on a JVM that erases generic types, so by default a generic function does not know what T is at run time — value is T will not even compile. The workaround is elegant and has no Swift analog: mark the function inline and the parameter reified, and the compiler substitutes the real type at each call site, so filterIsInstance<T>() works. Swift's generics are reified natively, so you never think about this. Variance is the other adjustment — Swift has no declaration-site variance, while Kotlin has out/in (covariant/contravariant) on type parameters.
Collections
Arrays and Dictionaries, mutable or not
// Mutability comes from let/var, not from the type. var numbers = [1, 2, 3] numbers.append(4) print(numbers) let fixed = [1, 2, 3] // fixed.append(4) ← will not compile: fixed is a let var ages = ["Ada": 36] ages["Alan"] = 41 print(ages["Ada"] ?? 0) // subscript gives an Optional print(ages["Nobody"] ?? 0) let names = ["Ada", "Grace", "Alan"] print(names.filter { $0.count > 3 }.map { $0.uppercased() }) print(names.sorted()) print(names.first { $0.hasPrefix("G") } ?? "(none)")
fun main() { // Mutability is in the TYPE: List is read-only, MutableList is not. val numbers = mutableListOf(1, 2, 3) numbers.add(4) // the val binding is fine — the LIST is mutable println(numbers) val fixed: List<Int> = listOf(1, 2, 3) // fixed.add(4) ← will not compile: List has no add() val ages = mutableMapOf("Ada" to 36) ages["Alan"] = 41 println(ages["Ada"]) // Int? — null when missing, like Swift println(ages["Nobody"] ?: 0) val names = listOf("Ada", "Grace", "Alan") println(names.filter { it.length > 3 }.map { it.uppercase() }) println(names.sorted()) println(names.firstOrNull { it.startsWith("G") } ?: "(none)") }
Same operations, different place for the mutability. Swift puts it on the binding (a let array cannot be appended to), while Kotlin puts it in the type (List has no add; MutableList does) — so val numbers = mutableListOf(…) is a constant reference to a mutable list, which reads strangely for about a day. Be aware that Kotlin's List is a read-only view, not a guarantee of immutability: whoever holds the underlying MutableList can still change it under you, which a Swift value-type array can never do.
lazy becomes asSequence
let numbers = [1, 2, 3, 4, 5] // Eager by default: each step allocates. let doubled = numbers.map { $0 * 2 }.filter { $0 > 4 } print(doubled) // .lazy opts into a fused, one-pass, short-circuiting pipeline. let firstBig = numbers.lazy.map { value in value * value }.first { $0 > 5 } print(firstBig ?? -1) // Infinite sequences. print(Array(sequence(first: 1) { $0 * 2 }.prefix(6)))
fun main() { val numbers = listOf(1, 2, 3, 4, 5) // Eager by default, exactly like Swift. println(numbers.map { it * 2 }.filter { it > 4 }) // asSequence() is .lazy: fused, one pass, short-circuiting. val firstBig = numbers.asSequence() .map { value -> value * value } .firstOrNull { it > 5 } println(firstBig ?: -1) // generateSequence is infinite and lazy. println(generateSequence(1) { it * 2 }.take(6).toList()) }
A direct translation: .lazy is .asSequence(), and both languages default to eager collection operations that allocate at every step. The advice is the same in both — the laziness pays for itself on a long chain, a large collection, or a short-circuiting search, and costs more than it saves on a three-element list.
Errors & Exceptions
throws disappears from the signature
Swift made errors part of the type system. Kotlin, having inherited the JVM and then rejected Java's checked exceptions, took them back out of it.
enum ParseError: Error { case notANumber(String) case notPositive(Int) } // 'throws' is part of the signature, and 'try' is required at every call site. func parsePositive(_ text: String) throws -> Int { guard let number = Int(text) else { throw ParseError.notANumber(text) } guard number > 0 else { throw ParseError.notPositive(number) } return number } do { print(try parsePositive("42")) print(try parsePositive("-1")) } catch ParseError.notPositive(let number) { print("not positive: \(number)") } catch { print("failed: \(error)") } // try? gives an Optional; try! traps. print(try? parsePositive("nope") ?? -1)
class NotANumber(val text: String) : Exception("not a number: $text") class NotPositive(val number: Int) : Exception("not positive: $number") // NO 'throws' in the signature, and no 'try' at the call site. Nothing tells a // caller this can fail — you find out from the docs, or from production. fun parsePositive(text: String): Int { val number = text.toIntOrNull() ?: throw NotANumber(text) if (number <= 0) throw NotPositive(number) return number } fun main() { try { println(parsePositive("42")) println(parsePositive("-1")) } catch (error: NotPositive) { println("not positive: ${error.number}") } catch (error: Exception) { println("failed: ${error.message}") } // runCatching is 'try?': it wraps the result (or the throwable) in a Result. println(runCatching { parsePositive("nope") }.getOrElse { -1 }) // And the toXOrNull family means most parsing needs no exception at all. println("nope".toIntOrNull() ?: -1) }
All Kotlin exceptions are unchecked, and there is no throws annotation, so a function's signature no longer tells you it can fail — the information Swift puts in the type system lives in the KDoc, or nowhere. In exchange, the happy path is uncluttered (no try on every call) and lambdas compose without fighting a throws clause. try is an expression in both languages, runCatching is try?, and Kotlin's toIntOrNull family means a lot of code that would throw in Swift simply returns null instead.
ARC vs Garbage Collection
No ARC, no deinit, no weak — and no defer
The JVM has a tracing garbage collector, which removes a whole category of thinking from your day, and one tool from your hand.
class Node { let name: String // Without 'weak', a parent/child cycle leaks: ARC never sees the count hit 0. weak var parent: Node? var children: [Node] = [] init(name: String) { self.name = name } // Deterministic destruction: this runs the instant the last reference dies. deinit { print("freeing \(name)") } } func build() { let parent = Node(name: "parent") let child = Node(name: "child") parent.children.append(child) child.parent = parent } build() // both deinits run HERE, deterministically print("done") // defer for scoped cleanup. func withResource() { print("open") defer { print("close") } print("work") } withResource()
class Node(val name: String) { // No 'weak' needed: a tracing GC collects cycles. A parent/child cycle with // ordinary references is simply not a leak. var parent: Node? = null val children = mutableListOf<Node>() // There is NO deinit. finalize() exists, is deprecated, and must never be // used: it runs at an unpredictable time, or never. } fun build() { val parent = Node("parent") val child = Node("child") parent.children.add(child) child.parent = parent } // nothing happens HERE. The GC will get to it, eventually, on its own schedule. // Cleanup is explicit and scoped: 'use' closes an AutoCloseable on every exit // path. This is Kotlin's defer — but it is tied to a RESOURCE, not to a scope. class Resource : AutoCloseable { init { println("open") } fun work() = println("work") override fun close() = println("close") } fun main() { build() println("done") Resource().use { resource -> resource.work() } }
Three habits to unlearn. Cycles are not a leak, so weak and unowned have no counterparts and no purpose — a tracing collector finds unreachable cycles that reference counting cannot. There is no deinit, and no deterministic destruction at all: finalize() is deprecated and effectively never runs, so anything that must be released (a file, a socket, a lock) is released explicitly. And there is no defer — the nearest thing is try/finally, or use { }, which closes an AutoCloseable on every exit path. Note the direction of the trade: you stop thinking about retain cycles, and you start thinking about resource lifetimes.
Concurrency
async/await becomes suspend, and Task becomes launch
Both languages have structured concurrency and colored functions, and they arrived at almost the same design. The vocabulary maps cleanly.
import Foundation func fetch(_ id: Int) async -> String { try? await Task.sleep(for: .milliseconds(10)) return "user-\(id)" } // A task group owns its children and waits for them. func fetchAll() async -> [String] { await withTaskGroup(of: String.self) { group in for id in 1...3 { group.addTask { await fetch(id) } } var results: [String] = [] for await result in group { results.append(result) } return results.sorted() } } print(await fetch(1)) print(await fetchAll())
import kotlinx.coroutines.* // 'suspend' is 'async': a function that can pause without blocking a thread. suspend fun fetch(id: Int): String { delay(10) // Task.sleep — and it does not block the thread return "user-$id" } suspend fun fetchAll(): List<String> = coroutineScope { // async { } returns a Deferred — Swift's addTask + the value. (1..3).map { id -> async { fetch(id) } } .awaitAll() .sorted() } // coroutineScope waits for every child before returning — the task group fun main() = runBlocking { println(fetch(1)) // no 'await' keyword: a suspend call looks ordinary println(fetchAll()) }
The mapping is unusually clean: async func is suspend fun, Task.sleep is delay, withTaskGroup is coroutineScope, addTask + a value is async { } returning a Deferred, and both scopes wait for their children and propagate cancellation down the tree. Two differences. Kotlin has no await keyword — calling a suspend function looks like an ordinary call, and the suspension is invisible at the call site (which reads more cleanly and hides more). And Kotlin makes you choose a dispatcher (Dispatchers.Default for CPU work, Dispatchers.IO for blocking calls, Dispatchers.Main for UI), where Swift's cooperative pool and @MainActor handle that with less ceremony.
There are no actors
// An actor serializes access to its own state, and the COMPILER enforces it: // every call from outside is async, and a data race on 'count' is unwritable. actor Counter { private var count = 0 func increment() { count += 1 } func value() -> Int { count } } let counter = Counter() await withTaskGroup(of: Void.self) { group in for _ in 1...4 { group.addTask { await counter.increment() } } } print(await counter.value()) // Swift 6 goes further: Sendable is checked at COMPILE time, so sharing // non-thread-safe state across a concurrency boundary is an error.
import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock // No actors, and no Sendable. Shared mutable state is protected by a Mutex you // take by hand — nothing in the type system connects the lock to the data. class Counter { private val mutex = Mutex() private var count = 0 suspend fun increment() = mutex.withLock { count += 1 } suspend fun value(): Int = mutex.withLock { count } } fun main() = runBlocking { val counter = Counter() coroutineScope { repeat(4) { launch { counter.increment() } } } println(counter.value()) // Forget withLock and this still compiles, and is wrong under contention. // Kotlin's answer is convention plus immutability, not a compiler check. }
This is the gap that will annoy you most. Kotlin has no actor and no Sendable: shared mutable state is protected by a Mutex (a suspending one, from kotlinx.coroutines.sync) that you must remember to take, and nothing in the type system connects the lock to the data it guards. Forgetting it compiles perfectly. The idiomatic defenses are the ones you already know from Swift — confine state to a single coroutine, pass immutable data, or funnel updates through a StateFlow — but they are conventions rather than guarantees. Coming from Swift 6's strict concurrency checking, this feels like a step backward, and it is.