OOP, Generics & Java Interop
Kotlin’s object-oriented features will feel familiar coming from TypeScript classes and Go structs, but Kotlin adds generics variance, sealed hierarchies, class delegation, and seamless Java interop that neither TS nor Go offer. This module maps your existing OOP knowledge to Kotlin and covers the JVM-specific features you need for backend development.
Classes & Constructors
Section titled “Classes & Constructors”Primary constructors
Section titled “Primary constructors”Kotlin’s primary constructor is part of the class header itself — there is no constructor body unless you need one.
class User { readonly id: string; name: string; email: string; private age: number;
constructor(id: string, name: string, email: string, age: number) { this.id = id; this.name = name; this.email = email; this.age = age; }}
const user = new User("u1", "Alice", "alice@corp.com", 30);type User struct { ID string Name string Email string age int // lowercase = unexported (package-private)}
// Go has no constructors -- convention is a NewXxx functionfunc NewUser(id, name, email string, age int) *User { return &User{ ID: id, Name: name, Email: email, age: age, }}
user := NewUser("u1", "Alice", "alice@corp.com", 30)class User( val id: String, // read-only property (like TS readonly) var name: String, // mutable property var email: String, private var age: Int // private property)
val user = User("u1", "Alice", "alice@corp.com", 30)// No `new` keyword -- Kotlin drops it entirely.Key differences:
- Kotlin’s primary constructor declares properties directly in the class header — no
repetitive
this.x = xassignments. val= read-only property,var= mutable property. This replaces TS’sreadonly.- Go has no constructors at all. Kotlin’s primary constructor is more concise than both
TS constructors and Go’s
NewXxxpattern. - No
newkeyword in Kotlin (same as Go, unlike TS/Java).
init blocks
Section titled “init blocks”The init block runs after the primary constructor. Use it for validation or computed
setup.
class User { constructor( readonly id: string, public name: string, public email: string, private age: number ) { // Validation logic goes in the constructor body if (!id.trim()) throw new Error("ID must not be blank"); if (age < 0 || age > 150) throw new Error("Age must be between 0 and 150"); this.name = name.trim(); this.email = email.toLowerCase(); }}class User( val id: String, var name: String, var email: String, private var age: Int) { // init runs when the object is created -- like constructor body logic init { require(id.isNotBlank()) { "ID must not be blank" } require(age in 0..150) { "Age must be between 0 and 150" } name = name.trim() email = email.lowercase() }
// You can have multiple init blocks -- they run in order init { println("User $id created") }}
// This will throw IllegalArgumentException at construction time// val bad = User("", "Alice", "alice@corp.com", 30)Secondary constructors
Section titled “Secondary constructors”Secondary constructors provide alternative ways to create an object. They must delegate to
the primary constructor using this(...).
// TypeScript uses optional parameters + defaults instead of overloadsclass User { readonly id: string; name: string; email: string; private age: number;
constructor(id: string, name?: string, email?: string, age?: number) { this.id = id; this.name = name ?? "Unknown"; this.email = email ?? `${this.name}@default.com`; this.age = age ?? 0; }}// Go uses multiple factory functionsfunc NewUser(id, name, email string, age int) *User { return &User{ID: id, Name: name, Email: email, age: age}}
func NewUserWithDefaults(id, name string) *User { return &User{ID: id, Name: name, Email: name + "@default.com"}}class User( val id: String, var name: String, var email: String, private var age: Int) { // Secondary constructor delegates to primary constructor(id: String, name: String) : this(id, name, "$name@default.com", 0)
// Another secondary constructor constructor(id: String) : this(id, "Unknown", "unknown@default.com", 0)}
val full = User("u1", "Alice", "alice@corp.com", 30)val partial = User("u2", "Bob") // uses secondary constructorval minimal = User("u3") // uses other secondary constructorKey differences:
- Kotlin secondary constructors always delegate to the primary constructor, ensuring all properties are initialized.
- In practice, Kotlin devs prefer default parameter values over secondary constructors:
class User(val id: String, var name: String = "Unknown"). - TS uses optional parameters + defaults. Go uses multiple factory functions.
Properties, getters, setters, and backing fields
Section titled “Properties, getters, setters, and backing fields”Kotlin properties look like fields but are actually getter/setter pairs. You can customize them without changing the call site.
class Account { private _balance: number; private _transactionCount: number = 0;
constructor(readonly id: string, initialBalance: number) { this._balance = initialBalance; }
get isActive(): boolean { return this._balance > 0; }
get balance(): number { return this._balance; }
set balance(value: number) { if (value < 0) throw new Error("Balance cannot be negative"); this._balance = value; }
get transactionCount(): number { return this._transactionCount; }
deposit(amount: number): void { if (amount <= 0) throw new Error("Deposit must be positive"); this._balance += amount; this._transactionCount++; }}type Account struct { ID string balance float64 // unexported -- private transactionCount int}
func (a *Account) Balance() float64 { return a.balance }func (a *Account) IsActive() bool { return a.balance > 0 }func (a *Account) TransactionCount() int { return a.transactionCount }
func (a *Account) Deposit(amount float64) error { if amount <= 0 { return errors.New("deposit must be positive") } a.balance += amount a.transactionCount++ return nil}class Account( val id: String, initialBalance: Double // No val/var = constructor parameter only, NOT a property) { // Property with custom getter (computed, no backing field) val isActive: Boolean get() = balance > 0.0
// Property with backing field + custom setter var balance: Double = initialBalance set(value) { require(value >= 0) { "Balance cannot be negative" } field = value // `field` refers to the backing field }
// Property with private setter var transactionCount: Int = 0 private set // readable from outside, writable only from inside
fun deposit(amount: Double) { require(amount > 0) { "Deposit must be positive" } balance += amount transactionCount++ }}Key differences:
- In Kotlin,
fieldis the auto-generated backing field — you only use it inside custom getters/setters. - If a constructor parameter has no
val/var, it’s just a parameter (not a property). It can only be used ininitblocks and property initializers. - Go has no properties — only exported/unexported fields with explicit getter methods.
- Kotlin’s
private setgives you read-only from outside, mutable inside — cleaner than TS’s separate_field+ getter pattern.
Visibility modifiers
Section titled “Visibility modifiers”| Modifier | Kotlin | TypeScript | Go |
|---|---|---|---|
public | Visible everywhere (default) | public (default) | Exported (capitalized name) |
private | Visible in same file/class | private | unexported (lowercase name) |
protected | Visible in class + subclasses | protected | N/A |
internal | Visible in same module | N/A | Package-level (sort of) |
class DatabaseConfig( val host: String, // public (default) private val password: String, // only inside this class protected val port: Int, // this class + subclasses internal val connectionPool: Int // same Gradle module) { // private function -- only callable inside this class private fun buildConnectionString(): String { return "jdbc:postgresql://$host:$port?password=$password" }
// internal function -- callable from same module internal fun getPoolSize(): Int = connectionPool}Key differences:
- Kotlin’s default is
public. TS’s default ispublic. Go’s default is unexported. - Kotlin’s
internalmeans “same Gradle module” — useful for libraries that want to expose a public API without leaking implementation details. TS has no equivalent. Go’s closest equivalent is package-level visibility. - Kotlin has no
package-privatevisibility (Java’s default). Useinternalinstead.
Interfaces & Abstract Classes
Section titled “Interfaces & Abstract Classes”Interfaces with default methods
Section titled “Interfaces with default methods”Kotlin interfaces can have method implementations (default methods) and abstract properties — they are much more powerful than Go interfaces.
interface Logger { // TS interfaces are purely structural -- no implementations log(message: string): void; error(message: string): void;}
class ConsoleLogger implements Logger { log(message: string): void { console.log(`[LOG] ${message}`); } error(message: string): void { console.error(`[ERROR] ${message}`); }}// Go interfaces are implicit -- no "implements" keywordtype Logger interface { Log(message string) Error(message string) // No default implementations in Go interfaces}
type ConsoleLogger struct{}
func (c *ConsoleLogger) Log(message string) { fmt.Printf("[LOG] %s\n", message)}func (c *ConsoleLogger) Error(message string) { fmt.Printf("[ERROR] %s\n", message)}// ConsoleLogger implicitly implements Loggerinterface Logger { // Abstract property -- implementors must provide it val prefix: String
// Abstract method -- must be implemented fun log(message: String)
// Default method -- can be overridden but has a default implementation fun error(message: String) { log("ERROR: $message") }
// Default method using the abstract property fun warn(message: String) { log("$prefix WARN: $message") }}
class ConsoleLogger : Logger { override val prefix = "[CONSOLE]"
override fun log(message: String) { println("$prefix $message") }
// error() and warn() are inherited with their default implementations override fun error(message: String) { System.err.println("$prefix ERROR: $message") }}Key differences:
- Kotlin interfaces can have default method implementations and abstract properties. TS interfaces cannot. Go interfaces cannot.
- Kotlin interface implementation is explicit:
class Foo : MyInterface. Go is implicit (structural). TS uses theimplementskeyword. - Kotlin interfaces cannot have state (no backing fields) — use abstract classes for that.
- Kotlin interfaces with default methods are similar to Java 8+ default methods and Rust traits.
Abstract classes
Section titled “Abstract classes”Abstract classes can have state (backing fields), constructors, and a mix of abstract and concrete members. Use them when you need shared state or constructor logic.
abstract class Repository<T>( protected val tableName: String // State -- interfaces cannot have this) { // Abstract -- subclasses must implement abstract fun findById(id: String): T? abstract fun save(entity: T): T
// Concrete -- shared implementation fun exists(id: String): Boolean = findById(id) != null
// Protected -- only subclasses can call protected fun logQuery(query: String) { println("[$tableName] Executing: $query") }}
class UserRepository : Repository<User>("users") { private val store = mutableMapOf<String, User>()
override fun findById(id: String): User? { logQuery("SELECT * FROM $tableName WHERE id = $id") return store[id] }
override fun save(entity: User): User { logQuery("INSERT INTO $tableName VALUES (${entity.id}, ${entity.name})") store[entity.id] = entity return entity }}
data class User(val id: String, val name: String)When to use interface vs abstract class
Section titled “When to use interface vs abstract class”| Use an interface when | Use an abstract class when |
|---|---|
| You define a contract/capability (what something can do) | You share state (backing fields) across subclasses |
| You want multiple inheritance (implement many interfaces) | You need a constructor with parameters |
| You don’t need shared state | You have a base implementation (template method) |
Examples: Serializable, Comparable, Logger | Examples: Repository base, HttpHandler base |
Multiple interface implementation
Section titled “Multiple interface implementation”A class can implement multiple interfaces, each contributing a capability.
interface Cacheable { val cacheKey: String fun ttlSeconds(): Int = 300 // default 5 minutes}
interface Serializable { fun toJson(): String}
interface Auditable { val createdAt: Long val updatedAt: Long fun auditLog(): String = "created=$createdAt, updated=$updatedAt"}
// A class can implement multiple interfacesdata class Product( val id: String, val name: String, val price: Double, override val createdAt: Long = System.currentTimeMillis(), override val updatedAt: Long = System.currentTimeMillis()) : Cacheable, Serializable, Auditable {
override val cacheKey: String get() = "product:$id"
override fun ttlSeconds(): Int = 600 // override default
override fun toJson(): String = """{"id":"$id","name":"$name","price":$price}"""}Compared to Go, which composes interfaces implicitly:
type Cacheable interface { CacheKey() string TTLSeconds() int}
type Serializable interface { ToJSON() string}
// Go uses composition of interfacestype CacheableSerializable interface { Cacheable Serializable}
// A struct implicitly satisfies both if it has all methodsfunc (p *Product) CacheKey() string { return "product:" + p.ID }func (p *Product) TTLSeconds() int { return 600 }func (p *Product) ToJSON() string { return fmt.Sprintf(`{"id":"%s"}`, p.ID) }Key differences:
- Kotlin multiple interface implementation is explicit and checked at compile time.
- Go multiple interface satisfaction is implicit — you find out at runtime if a type is missing a method (or at compile time if you assign it to an interface variable).
- If two interfaces have a method with the same signature, Kotlin requires you to override
it and choose which
superimplementation to call.
Resolving diamond conflicts
Section titled “Resolving diamond conflicts”When two interfaces both provide a default method with the same signature, you must
override and pick — using the super<A> / super<B> syntax.
interface A { fun greet(): String = "Hello from A"}
interface B { fun greet(): String = "Hello from B"}
class C : A, B { // Must override because both A and B provide greet() override fun greet(): String { // Can call either or both return "${super<A>.greet()} and ${super<B>.greet()}" }}
fun main() { println(C().greet()) // Hello from A and Hello from B}Inheritance & Composition
Section titled “Inheritance & Composition”open/override — Kotlin classes are final by default
Section titled “open/override — Kotlin classes are final by default”In Kotlin, classes and methods are final by default. You must explicitly mark them open
to allow inheritance.
// All classes are open for extension by defaultclass Animal { constructor(protected name: string) {}
speak(): string { return `${this.name} makes a sound`; }}
class Dog extends Animal { speak(): string { return `${this.name} barks`; }}// Go uses embedding (composition), not inheritancetype Animal struct { Name string}
func (a *Animal) Speak() string { return a.Name + " makes a sound"}
type Dog struct { Animal // embedded -- promotes methods}
// This shadows Animal.Speak, it does NOT override itfunc (d *Dog) Speak() string { return d.Name + " barks"}// Classes are FINAL by default -- must use `open` to allow subclassingopen class Animal(protected val name: String) { // Methods are FINAL by default -- must use `open` to allow overriding open fun speak(): String = "$name makes a sound"
// This method CANNOT be overridden (no `open`) fun species(): String = "Animal"}
class Dog(name: String) : Animal(name) { // Must use `override` keyword -- not optional like in TS override fun speak(): String = "$name barks"}
// class Cat : Dog("Cat") { } // COMPILE ERROR: Dog is not open
fun main() { val dog: Animal = Dog("Rex") println(dog.speak()) // Rex barks (polymorphic dispatch)}Key differences:
- Kotlin: classes and methods are
finalby default. This prevents the “fragile base class” problem. You must deliberately design for inheritance withopen. - TypeScript: everything is open by default. No way to prevent overriding.
- Go: no inheritance at all. Embedding is composition with method promotion, not
polymorphic dispatch.
Dog.Speak()shadowsAnimal.Speak()— it does not participate in virtual dispatch. - Kotlin’s
overridekeyword is mandatory (unlike TS where it is optional).
Sealed classes and interfaces
Section titled “Sealed classes and interfaces”Sealed classes restrict which classes can extend them. The compiler knows all subtypes,
enabling exhaustive when expressions. This is Kotlin’s answer to discriminated unions in
TypeScript and sum types in other languages.
// Discriminated uniontype Result<T> = | { kind: "success"; value: T } | { kind: "failure"; error: string } | { kind: "loading" };
function handle(result: Result<string>): string { switch (result.kind) { case "success": return result.value; case "failure": return `Error: ${result.error}`; case "loading": return "Loading..."; // TS does not enforce exhaustiveness unless you use `never` trick }}// No sum types -- use interfaces + type switchtype Result interface { isResult() // marker method}
type Success struct{ Value string }type Failure struct{ Error string }type Loading struct{}
func (Success) isResult() {}func (Failure) isResult() {}func (Loading) isResult() {}
func handle(r Result) string { switch v := r.(type) { case Success: return v.Value case Failure: return "Error: " + v.Error case Loading: return "Loading..." default: return "Unknown" // anyone can implement Result -- not sealed }}// sealed = only subclasses defined in the same file (or package for sealed interface)sealed class Result<out T> { data class Success<T>(val value: T) : Result<T>() data class Failure(val error: String) : Result<Nothing>() data object Loading : Result<Nothing>()}
fun handle(result: Result<String>): String = when (result) { is Result.Success -> result.value // smart cast -- result is now Success<String> is Result.Failure -> "Error: ${result.error}" is Result.Loading -> "Loading..." // No `else` needed -- compiler knows these are ALL the subtypes}Key differences:
- Kotlin sealed classes are truly closed — the compiler enforces exhaustiveness in
whenexpressions. If you add a new subclass, everywhenthat handles the sealed type gets a compile error until you handle the new case. - TS discriminated unions provide similar exhaustiveness, but only with the
nevertrick and only forswitchon a discriminant property. - Go has no sealed types — anyone can implement any interface. Type switches need a
defaultbranch. - Sealed interfaces (Kotlin 1.5+) allow subtypes in the same package across files.
Real-world sealed hierarchy: API responses
Section titled “Real-world sealed hierarchy: API responses”A sealed interface models the closed set of outcomes for an API call, so each when over
it is exhaustive.
sealed interface ApiResponse<out T> { data class Ok<T>(val data: T, val statusCode: Int = 200) : ApiResponse<T> data class Error(val message: String, val statusCode: Int) : ApiResponse<Nothing> data class Redirect(val url: String, val statusCode: Int = 302) : ApiResponse<Nothing>}
sealed interface ValidationResult { data object Valid : ValidationResult data class Invalid(val errors: List<String>) : ValidationResult}
fun <T> processResponse(response: ApiResponse<T>): String = when (response) { is ApiResponse.Ok -> "Success (${response.statusCode}): ${response.data}" is ApiResponse.Error -> "Error (${response.statusCode}): ${response.message}" is ApiResponse.Redirect -> "Redirect to ${response.url}"}
fun validate(email: String): ValidationResult { val errors = buildList { if (!email.contains("@")) add("Must contain @") if (email.length < 5) add("Too short") if (email.contains(" ")) add("Must not contain spaces") } return if (errors.isEmpty()) ValidationResult.Valid else ValidationResult.Invalid(errors)}Composition over inheritance — Kotlin delegation with by
Section titled “Composition over inheritance — Kotlin delegation with by”Kotlin’s by keyword implements the delegate pattern at the language level. Instead of
inheriting from a class, you delegate to an instance.
interface Logger { log(message: string): void;}
class ConsoleLogger implements Logger { log(message: string): void { console.log(`[${new Date().toISOString()}] ${message}`); }}
// Manual delegation -- you must forward every methodclass UserService { private logger: Logger;
constructor(logger: Logger) { this.logger = logger; }
// Forwarding method log(message: string): void { this.logger.log(message); }
createUser(name: string): void { this.log(`Creating user: ${name}`); }}type Logger interface { Log(message string)}
type ConsoleLogger struct{}
func (c *ConsoleLogger) Log(message string) { fmt.Printf("[%s] %s\n", time.Now().Format(time.RFC3339), message)}
// Go embedding auto-promotes methodstype UserService struct { *ConsoleLogger // Log() is promoted -- no manual forwarding}
func (s *UserService) CreateUser(name string) { s.Log("Creating user: " + name)}interface Logger { fun log(message: String) fun error(message: String)}
class ConsoleLogger : Logger { override fun log(message: String) = println("[LOG] $message") override fun error(message: String) = System.err.println("[ERROR] $message")}
class FileLogger(private val filename: String) : Logger { override fun log(message: String) { println("[FILE:$filename] $message") } override fun error(message: String) { println("[FILE:$filename] ERROR: $message") }}
// `by logger` auto-generates all forwarding methodsclass UserService(logger: Logger) : Logger by logger { fun createUser(name: String) { log("Creating user: $name") // calls logger.log() via delegation }
// You can override specific delegated methods override fun error(message: String) { println("!!! UserService error !!!") // Note: cannot call super for delegated methods System.err.println("[ERROR] $message") }}Key differences:
- Kotlin
bygenerates all forwarding methods at compile time — zero boilerplate. - Go embedding promotes methods automatically but is not true delegation (the embedded
struct’s methods see the inner struct as
this, not the outer struct). - TS requires manual forwarding of each method.
- Kotlin delegation lets you override specific methods while delegating the rest.
- You can delegate to multiple interfaces:
class Foo(a: A, b: B) : A by a, B by b.
Multi-interface delegation
Section titled “Multi-interface delegation”You can delegate each interface to a different implementation, composing capabilities without inheritance.
interface Cache { fun get(key: String): String? fun put(key: String, value: String)}
interface Metrics { fun increment(counter: String) fun gauge(name: String, value: Double)}
class InMemoryCache : Cache { private val store = mutableMapOf<String, String>() override fun get(key: String): String? = store[key] override fun put(key: String, value: String) { store[key] = value }}
class SimpleMetrics : Metrics { private val counters = mutableMapOf<String, Long>() override fun increment(counter: String) { counters[counter] = (counters[counter] ?: 0) + 1 } override fun gauge(name: String, value: Double) { println(" [metric] $name = $value") }}
// Delegate both interfaces to different implementationsclass CachedUserService( cache: Cache, metrics: Metrics) : Cache by cache, Metrics by metrics {
fun getUser(id: String): String { increment("user.get") // delegated to Metrics return get("user:$id") // delegated to Cache ?: "not found".also { put("user:$id", it) } }}Generics Deep Dive
Section titled “Generics Deep Dive”Basic generics
Section titled “Basic generics”Generics work similarly across Kotlin, TypeScript, and Go.
class Box<T> { constructor(private value: T) {}
get(): T { return this.value; }
map<U>(transform: (value: T) => U): Box<U> { return new Box(transform(this.value)); }}
const stringBox = new Box("hello");const lengthBox = stringBox.map(s => s.length); // Box<number>type Box[T any] struct { value T}
func NewBox[T any](value T) Box[T] { return Box[T]{value: value}}
func (b Box[T]) Get() T { return b.value}
// Go generics cannot have type parameters on methods, only on types/functionsfunc Map[T any, U any](b Box[T], transform func(T) U) Box[U] { return NewBox(transform(b.value))}class Box<T>(private val value: T) { fun get(): T = value
fun <U> map(transform: (T) -> U): Box<U> = Box(transform(value))}
fun main() { val stringBox = Box("hello") val lengthBox = stringBox.map { it.length } // Box<Int> println(lengthBox.get()) // 5}Key differences:
- Syntax is nearly identical across all three.
- Go generics cannot have type parameters on methods — you must use top-level functions.
- Kotlin and TS can have generic methods on classes.
- Kotlin uses
itas the implicit lambda parameter name (like$0in Swift).
Variance: out (covariant) and in (contravariant)
Section titled “Variance: out (covariant) and in (contravariant)”Variance controls subtyping relationships for generic types. This is where Kotlin shines
and where TS and Go diverge. The core question: should List<Dog> be assignable to
List<Animal>? If the list is read-only, yes — reading a Dog is fine when you expect an
Animal. If it is mutable, no — someone could add a Cat to a “list of dogs.”
class Animal { constructor(public name: string) {} }class Dog extends Animal {}class Cat extends Animal {}
// TS allows this -- but it is UNSOUND (a known TS design choice)const dogs: Dog[] = [new Dog("Rex")];const animals: Animal[] = dogs; // Allowed! TS arrays are covariantanimals.push(new Cat("Whiskers")); // Compiles! But now dogs contains a Catconsole.log(dogs[1]); // Cat at runtime -- type system lied// Go generics have NO variance at all// []Dog is NOT assignable to []Animal -- periodvar dogs []Dog = []Dog{{Name: "Rex"}}// var animals []Animal = dogs // COMPILE ERROR
// You must copy explicitlyanimals := make([]Animal, len(dogs))for i, d := range dogs { animals[i] = d}// out = covariant: Producer<Dog> is a subtype of Producer<Animal>// T only appears in "out" positions (return types)interface Producer<out T> { fun produce(): T // fun consume(item: T) // COMPILE ERROR: T is in "in" position}
// in = contravariant: Consumer<Animal> is a subtype of Consumer<Dog>// T only appears in "in" positions (parameter types)interface Consumer<in T> { fun consume(item: T) // fun produce(): T // COMPILE ERROR: T is in "out" position}
// No variance annotation = invariant (default)interface MutableBox<T> { fun get(): T // T in out position fun set(item: T) // T in in position}
fun main() { val dogProducer: Producer<Dog> = object : Producer<Dog> { override fun produce(): Dog = Dog("Rex") }
// out T: Producer<Dog> IS-A Producer<Animal> -- safe because you only read val animalProducer: Producer<Animal> = dogProducer // OK! val animal: Animal = animalProducer.produce() // Gets a Dog, typed as Animal
val animalConsumer: Consumer<Animal> = object : Consumer<Animal> { override fun consume(item: Animal) { println("Consuming ${item.name}") } }
// in T: Consumer<Animal> IS-A Consumer<Dog> -- safe because you only write val dogConsumer: Consumer<Dog> = animalConsumer // OK! dogConsumer.consume(Dog("Rex")) // Animal consumer handles Dog fine}Kotlin’s built-in collections use variance — List<out E> is read-only and covariant,
while MutableList<E> is read-write and invariant:
// List<out E> -- read-only, covariantval dogs: List<Dog> = listOf(Dog("Rex"), Dog("Buddy"))val animals: List<Animal> = dogs // OK! List is covariant (out E)
// MutableList<E> -- read-write, invariantval mutableDogs: MutableList<Dog> = mutableListOf(Dog("Rex"))// val mutableAnimals: MutableList<Animal> = mutableDogs // COMPILE ERROR -- invariantKey differences:
- Kotlin has declaration-site variance (
out/inon the type parameter) — the class author declares variance once, and all usage sites benefit. - TypeScript has no variance annotations. Arrays are unsoundly covariant by design.
- Go has no variance at all. Generic types are always invariant.
- Kotlin
out= Java? extends T(covariant). Kotlinin= Java? super T(contravariant). Kotlin’s syntax is much cleaner.
Use-site variance (type projections)
Section titled “Use-site variance (type projections)”Sometimes a class is invariant, but you want to use it covariantly or contravariantly at a
specific call site. Array<out Animal> says “I will only read”; Array<in Dog> says “I
will only write.”
// Array is invariant (it is mutable), but sometimes you just want to READ from itfun copy(from: Array<out Animal>, to: Array<Animal>) { // ^^^^^^^^^^^^^^^^^ use-site covariance: "I will only read from `from`" for (i in from.indices) { to[i] = from[i] } // from[0] = Dog("X") // COMPILE ERROR: out-projected, cannot write}
fun addAll(to: Array<in Dog>, dogs: List<Dog>) { // ^^^^^^^^^^^^^ use-site contravariance: "I will only write Dogs into `to`" for (dog in dogs) { to[dogs.indexOf(dog)] = dog } // val x: Dog = to[0] // COMPILE ERROR: in-projected, can only read as Any?}
fun main() { val dogs = arrayOf(Dog("Rex"), Dog("Buddy")) val animals = arrayOf<Animal>(Cat("Whiskers"), Cat("Shadow"))
copy(dogs, animals) // OK: Array<Dog> is Array<out Animal> println(animals.map { it.name }) // [Rex, Buddy]}Type bounds
Section titled “Type bounds”Type bounds constrain what types can be used as generic arguments.
interface Comparable<T> { compareTo(other: T): number;}
function max<T extends Comparable<T>>(a: T, b: T): T { return a.compareTo(b) >= 0 ? a : b;}
// Multiple bounds: use intersectionfunction process<T extends Serializable & Comparable<T>>(item: T): string { return item.serialize();}// Type constraintstype Ordered interface { ~int | ~float64 | ~string}
func Max[T Ordered](a, b T) T { if a >= b { return a } return b}
// Multiple constraints -- embed interfacestype OrderedStringer interface { Ordered fmt.Stringer}// Single upper bound with colonfun <T : Comparable<T>> max(a: T, b: T): T { return if (a >= b) a else b}
// Multiple upper bounds with `where` clausefun <T> sort(list: List<T>): List<T> where T : Comparable<T>, T : Serializable { return list.sorted()}
// Upper bound on class-level type parameterclass SortedList<T : Comparable<T>> { private val items = mutableListOf<T>()
fun add(item: T) { items.add(item) items.sort() }
fun getAll(): List<T> = items.toList()}Key differences:
- Kotlin uses
:for single bounds,wherefor multiple bounds. - TS uses
extendsfor bounds,&for intersection types. - Go uses type constraints (interface-based), with
~for underlying types. - Kotlin’s
whereclause is cleaner than Java’s verbose bounds syntax.
Star projection <*>
Section titled “Star projection <*>”Star projection is Kotlin’s way of saying “I don’t know (or care about) the type
argument.” It is like TS’s unknown and Java’s <?>.
// unknown = "I don't know the type"function printLength(box: Box<unknown>): void { // Cannot call box.get() usefully -- it returns unknown console.log("Has a value");}
// any = "I don't care about the type" (unsafe)function printAny(box: Box<any>): void { console.log(box.get()); // returns any -- no type safety}// any = interface{} = "I don't know the type"func printBox(box Box[any]) { fmt.Println(box.Get()) // returns any}// Star projection: Box<*> means "Box of something, but I don't know what"fun printBox(box: Box<*>) { // get() returns Any? -- safe, but you lose specific type info val value: Any? = box.get() println("Value: $value")}
// For variance-annotated types, star projection is smart:// Producer<out T> -> Producer<*> means Producer<out Any?> (can read Any?)// Consumer<in T> -> Consumer<*> means Consumer<in Nothing> (cannot write anything)Key differences:
- Kotlin
<*>is like TS<unknown>— safe but restrictive. - Java equivalent is
<?>. - Go’s
anyis more like TS’sany— it loses all type information. - Kotlin
<*>interacts with variance:List<*>=List<out Any?>(you can readAny?but cannot add anything).
Reified type parameters
Section titled “Reified type parameters”This is unique to Kotlin. Due to JVM type erasure, generic types are not available at
runtime — unless you use reified with inline functions.
// TS types don't exist at runtime either -- fully erasedfunction isType<T>(value: any): value is T { // Cannot check T at runtime return true; // useless}
// You must pass a constructor/class referencefunction isTypeChecked<T>(value: any, ctor: new (...args: any[]) => T): value is T { return value instanceof ctor;}// Go preserves type info at runtime -- no reified neededfunc isType[T any](value any) bool { _, ok := value.(T) return ok}// `reified` preserves the type parameter at runtime// Only works with `inline` functions (the type is substituted at the call site)inline fun <reified T> isType(value: Any): Boolean { return value is T // Works! T is known at runtime}
inline fun <reified T> List<Any>.filterByType(): List<T> { return this.filter { it is T }.map { it as T } // Or simply: return this.filterIsInstance<T>()}
inline fun <reified T> typeNameOf(): String { return T::class.simpleName ?: "Unknown"}
fun main() { println(isType<String>("hello")) // true println(isType<Int>("hello")) // false
val mixed: List<Any> = listOf(1, "hello", 2, "world", 3.14) val strings: List<String> = mixed.filterByType<String>() // [hello, world] val ints: List<Int> = mixed.filterByType<Int>() // [1, 2]}A practical use: a type-safe service locator that needs no Class<T> parameter and no
string keys.
class ServiceLocator { private val services = mutableMapOf<String, Any>()
// Register a service -- the class name is used as the key inline fun <reified T : Any> register(service: T) { services[T::class.qualifiedName ?: T::class.simpleName!!] = service }
// Retrieve a service by type -- no need to pass Class<T> inline fun <reified T : Any> get(): T { val key = T::class.qualifiedName ?: T::class.simpleName!! return services[key] as? T ?: throw IllegalStateException("Service not found: $key") }
inline fun <reified T : Any> getOrNull(): T? { val key = T::class.qualifiedName ?: T::class.simpleName!! return services[key] as? T }}
fun main() { val locator = ServiceLocator() locator.register<UserRepository>(InMemoryUserRepository())
// Retrieve by type -- no string keys, no Class<T> parameter val repo = locator.get<UserRepository>() println(repo.findById("u1")) // Alice}Key differences:
reifiedis Kotlin-only. It eliminates the need forClass<T>parameters that Java requires for runtime type checks.- TS erases types completely at compile time (even more aggressively than the JVM).
- Go preserves full type info at runtime — no need for a
reifiedequivalent. reifiedonly works withinlinefunctions because the compiler substitutes the actual type at each call site.
Type erasure: the JVM reality
Section titled “Type erasure: the JVM reality”On the JVM, generic type arguments are erased at runtime. List<String> and List<Int>
are both just List at runtime.
fun main() { val strings: List<String> = listOf("a", "b") val ints: List<Int> = listOf(1, 2)
// At runtime, both are just java.util.ArrayList -- type argument is gone println(strings.javaClass) // class java.util.Arrays$ArrayList
// This DOES NOT WORK at runtime: // if (strings is List<String>) { } // COMPILE ERROR: Cannot check erased type
// This works but only checks the raw type: if (strings is List<*>) { println("It's a list of something") }}
// Workaround: Pass KClass when reified is not possiblefun <T : Any> deserialize(json: String, type: kotlin.reflect.KClass<T>): T { println("Deserializing to ${type.simpleName}") @Suppress("UNCHECKED_CAST") return when (type) { String::class -> json as T Int::class -> json.toInt() as T else -> throw IllegalArgumentException("Unsupported type: ${type.simpleName}") }}The three escape hatches when erasure gets in your way: use reified (for inline
functions), pass a KClass<T> parameter, or run is checks on the elements rather than
the container.
Java Interop Essentials
Section titled “Java Interop Essentials”Platform types (T!)
Section titled “Platform types (T!)”When Kotlin calls Java code, it encounters “platform types” — types where Kotlin does not
know whether the value is nullable or not. Platform types are shown as T! in error
messages and IDE hints.
// Java code -- no nullability annotationspublic class JavaUserService { public String findUserName(String id) { if (id.equals("u1")) return "Alice"; return null; // Returns null! But Java doesn't tell you. }
public List<String> getAllNames() { return Arrays.asList("Alice", "Bob", null); // null in the list! }}// Kotlin code calling Javafun main() { val service = JavaUserService()
// service.findUserName("u1") returns String! (platform type) // You can treat it as String or String? -- Kotlin trusts you
// DANGEROUS: treating it as non-null when it could be null val name: String = service.findUserName("u1") // OK, returns "Alice" // val bad: String = service.findUserName("u999") // RUNTIME NPE! Returns null
// SAFE: treating it as nullable val safeName: String? = service.findUserName("u999") println(safeName?.uppercase() ?: "not found") // not found
// SAFE: list elements could be null val names: List<String?> = service.getAllNames() names.filterNotNull().forEach { println(it) }}The rule: always treat Java return values as nullable unless the Java code uses nullability
annotations. Kotlin recognizes many of them — JetBrains (@Nullable, @NotNull), Android
(@Nullable, @NonNull), JSR-305, Jakarta, and Spring. An @NotNull return becomes
String; an @Nullable return becomes String?.
Calling Kotlin from Java: @Jvm annotations
Section titled “Calling Kotlin from Java: @Jvm annotations”When Java code needs to call Kotlin code, annotations help make Kotlin code look natural from Java.
package com.example
class UserService( val defaultRole: String = "viewer") { companion object { // Without @JvmStatic, Java must call: UserService.Companion.create("Alice") @JvmStatic fun create(name: String): UserService = UserService()
// Without @JvmField, Java must call: UserService.Companion.getMAX_USERS() @JvmField val MAX_USERS = 1000 }
// @JvmOverloads generates Java overloads for default parameters @JvmOverloads fun greet(name: String, greeting: String = "Hello", punctuation: String = "!"): String { return "$greeting, $name$punctuation" }}public class JavaCaller { public static void main(String[] args) { // @JvmStatic -- call like a real static method UserService service = UserService.create("Alice");
// @JvmField -- access like a real static field int max = UserService.MAX_USERS;
// @JvmOverloads -- all these work from Java: service.greet("Alice", "Hello", "!"); // all params service.greet("Alice", "Hello"); // default punctuation service.greet("Alice"); // default greeting + punctuation }}The full set of interop annotations:
| Annotation | Purpose | Without it (from Java) |
|---|---|---|
@JvmStatic | Make companion member a real Java static | Foo.Companion.method() |
@JvmField | Expose property as a public field | Foo.Companion.getPROP() |
@JvmOverloads | Generate overloads for default params | Must pass all params |
@JvmName | Change generated method/class name | Uses Kotlin-generated name |
@file:JvmName | Change filename-based class name | FileNameKt.function() |
@Throws | Declare checked exceptions | Java cannot catch exceptions |
@Throws — checked exceptions
Section titled “@Throws — checked exceptions”Kotlin has no checked exceptions, but Java does. Use @Throws to let Java code handle
exceptions properly.
class FileProcessor { @Throws(java.io.IOException::class) fun readFile(path: String): String { return java.io.File(path).readText() }}// Java can now catch the exception properlytry { String content = new FileProcessor().readFile("data.txt");} catch (IOException e) { // Without @Throws, Java compiler does not know this can throw IOException System.err.println("File error: " + e.getMessage());}Key Java classes every Kotlin dev needs
Section titled “Key Java classes every Kotlin dev needs”BigDecimal — precise decimal arithmetic. Never use Double for money; BigDecimal
is exact, and Kotlin operator overloading lets you use +, -, * directly.
import java.math.BigDecimalimport java.math.RoundingMode
fun main() { println(0.1 + 0.2) // 0.30000000000000004 -- Double is imprecise
val price = BigDecimal("19.99") val quantity = BigDecimal("3") val subtotal = price * quantity // operator overloading works val tax = subtotal * BigDecimal("0.08") val total = subtotal + tax
println("Total: ${total.setScale(2, RoundingMode.HALF_UP)}") // 64.77
// Comparisons val a = BigDecimal("1.0") val b = BigDecimal("1.00") println(a == b) // false! (equals checks scale) println(a.compareTo(b)) // 0 (compareTo ignores scale -- use this)}java.time — modern date/time API. The standard for dates, times, durations, and
zones on the JVM.
import java.time.*import java.time.format.DateTimeFormatterimport java.time.temporal.ChronoUnit
fun main() { val now = Instant.now() // UTC timestamp val today = LocalDate.now() // Date only (no time, no zone) val zoned = ZonedDateTime.now(ZoneId.of("America/New_York"))
// Parsing & formatting val date = LocalDate.parse("2024-03-15") println(date.format(DateTimeFormatter.ofPattern("MMMM d, yyyy"))) // March 15, 2024
// Arithmetic val nextWeek = today.plusWeeks(1) val daysBetween = ChronoUnit.DAYS.between(date, today)
// Duration and Period val duration = Duration.ofHours(2).plusMinutes(30) val period = Period.between(date, today)}Optional — handling Java APIs that return it. Convert a Java Optional to a Kotlin
nullable immediately; never write new Kotlin APIs that return Optional.
import java.util.Optional
fun javaStyleFind(id: String): Optional<String> = if (id == "u1") Optional.of("Alice") else Optional.empty()
fun main() { // Convert Java Optional to Kotlin nullable -- DO THIS IMMEDIATELY val name: String? = javaStyleFind("u1").orElse(null) println(name) // Alice
// The Kotlin way: just return a nullable type fun findUser(id: String): String? = if (id == "u1") "Alice" else null val user = findUser("u1") ?: "not found"}Java collections vs Kotlin collections
Section titled “Java collections vs Kotlin collections”Under the hood, Kotlin collections ARE Java collections. listOf(1, 2, 3) returns a
java.util.Arrays$ArrayList. The difference is in the type system, not the runtime.
import java.util.Collections
fun main() { // Kotlin's List<T> is java.util.List at runtime val kotlinList: List<String> = listOf("a", "b", "c") println(kotlinList.javaClass) // class java.util.Arrays$ArrayList
// You can pass Kotlin collections to Java methods directly val mutableList: MutableList<String> = mutableListOf("a", "b") val javaUnmodifiable = Collections.unmodifiableList(mutableList)
// Convert between them val kotlinFromJava: List<String> = mutableList.toList() // copy}SAM conversions (functional interfaces)
Section titled “SAM conversions (functional interfaces)”A SAM (Single Abstract Method) interface is a Java interface with exactly one abstract method. Kotlin lets you use a lambda instead of creating an anonymous class.
import java.util.concurrent.Callableimport java.util.concurrent.Executors
// Runnable: void run() | Callable<V>: V call() | Comparator<T>: int compare(T, T)fun main() { // Without SAM conversion (Java style -- verbose) val runnableOldStyle = object : Runnable { override fun run() { println("Running the old way") } }
// With SAM conversion (Kotlin style -- concise) val runnable = Runnable { println("Running with SAM conversion") }
val executor = Executors.newSingleThreadExecutor() executor.submit { println("Task on ${Thread.currentThread().name}") }
// Callable<T> SAM conversion val future = executor.submit(Callable { "computed result" }) println(future.get()) // computed result
// Comparator SAM conversion val names = mutableListOf("Charlie", "Alice", "Bob") names.sortWith { a, b -> a.length - b.length } println(names) // [Bob, Alice, Charlie]
executor.shutdown()}
// Kotlin's own functional interfaces (Kotlin 1.4+)fun interface Predicate<T> { fun test(value: T): Boolean}
fun <T> List<T>.filterWith(predicate: Predicate<T>): List<T> = filter { predicate.test(it) }Key differences:
- TS has no SAM concept — you just use function types directly:
(x: string) => void. - Go also uses function types directly:
func(string). - SAM conversion is only needed because Java has a tradition of using single-method
interfaces instead of function types (
Runnable,Callable,Comparator, etc.). - Kotlin’s
fun interfacekeyword creates a Kotlin SAM interface that also supports lambda conversion.
Working with Java libraries
Section titled “Working with Java libraries”The JVM ecosystem is the real payoff of interop — Jackson, Guava, and Apache Commons all work seamlessly from Kotlin.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapperimport com.fasterxml.jackson.module.kotlin.readValue
data class ApiUser( val id: String, val name: String, val email: String, val roles: List<String> = emptyList())
fun main() { val mapper = jacksonObjectMapper() // Kotlin-aware ObjectMapper
val user = ApiUser("u1", "Alice", "alice@corp.com", listOf("admin", "user")) val json = mapper.writeValueAsString(user)
// Deserialize -- reified type parameter, no TypeReference needed val parsed: ApiUser = mapper.readValue(json)
// Deserialize a list val listJson = """[{"id":"u1","name":"Alice","email":"a@b.com"}]""" val users: List<ApiUser> = mapper.readValue(listJson)}import com.google.common.base.Splitterimport com.google.common.cache.CacheBuilderimport org.apache.commons.lang3.StringUtilsimport java.util.concurrent.TimeUnit
fun examples() { val parts = Splitter.on(",").trimResults().omitEmptyStrings() .splitToList("Alice, Bob, , Charlie") // [Alice, Bob, Charlie]
val cache = CacheBuilder.newBuilder() .maximumSize(100) .expireAfterWrite(10, TimeUnit.MINUTES) .build<String, String>()
StringUtils.abbreviate("Hello World from Kotlin", 15) // Hello World... StringUtils.isAlphanumeric("Hello123") // true}Design Patterns in Kotlin
Section titled “Design Patterns in Kotlin”Kotlin’s language features make many classic design patterns trivial or unnecessary. Here is how each pattern maps across TypeScript, Go, and Kotlin.
Singleton: object declaration
Section titled “Singleton: object declaration”class Database { private static instance: Database | null = null; private constructor() {}
static getInstance(): Database { if (!Database.instance) { Database.instance = new Database(); } return Database.instance; }
query(sql: string): string { return `Result of: ${sql}`; }}
const db = Database.getInstance();type Database struct { connected bool}
var ( dbInstance *Database dbOnce sync.Once)
func GetDatabase() *Database { dbOnce.Do(func() { dbInstance = &Database{connected: true} }) return dbInstance}// `object` declaration = thread-safe singleton, initialized lazily on first accessobject Database { init { println("Database initialized") // runs once on first access }
fun query(sql: String): String = "Result of: $sql"}
fun main() { // No getInstance() needed -- just use the object name val result = Database.query("SELECT * FROM users")
// It's a real singleton val db1 = Database val db2 = Database println(db1 === db2) // true (same instance)}Key differences:
- Kotlin
objectis a first-class language feature. Thread-safe. Lazy. Zero boilerplate. - TS requires the manual singleton pattern with a private constructor.
- Go requires
sync.Oncefor thread-safe singleton initialization. - Kotlin
objectcompiles to a Java class with anINSTANCEstatic field.
Factory: companion object factory methods
Section titled “Factory: companion object factory methods”class Connection { private constructor( private host: string, private port: number, private ssl: boolean ) {}
static createLocal(): Connection { return new Connection("localhost", 5432, false); }
static createProduction(host: string): Connection { return new Connection(host, 5432, true); }}type Connection struct { Host string Port int SSL bool}
func NewLocalConnection() *Connection { return &Connection{Host: "localhost", Port: 5432, SSL: false}}
func NewProductionConnection(host string) *Connection { return &Connection{Host: host, Port: 5432, SSL: true}}class Connection private constructor( val host: String, val port: Int, val ssl: Boolean) { // companion object = static methods in Java, associated functions in Rust companion object { fun createLocal() = Connection("localhost", 5432, false)
fun createProduction(host: String) = Connection(host, 5432, true)
fun fromConnectionString(connStr: String): Connection { val parts = connStr.removePrefix("jdbc:postgresql://").split(":") return Connection( host = parts[0], port = parts.getOrNull(1)?.toIntOrNull() ?: 5432, ssl = false ) } }
override fun toString() = "Connection($host:$port, ssl=$ssl)"}Builder: apply {} pattern
Section titled “Builder: apply {} pattern”Kotlin’s scope functions eliminate the need for a separate builder class in most cases.
class HttpRequest { url: string = ""; method: string = "GET"; headers: Record<string, string> = {}; body?: string; timeout: number = 30000;}
class HttpRequestBuilder { private request = new HttpRequest();
setUrl(url: string): this { this.request.url = url; return this; } setMethod(method: string): this { this.request.method = method; return this; } addHeader(key: string, value: string): this { this.request.headers[key] = value; return this; } setBody(body: string): this { this.request.body = body; return this; } build(): HttpRequest { return this.request; }}
const request = new HttpRequestBuilder() .setUrl("https://api.example.com/users") .setMethod("POST") .addHeader("Content-Type", "application/json") .build();type HttpRequest struct { URL string Method string Headers map[string]string Body string Timeout time.Duration}
type Option func(*HttpRequest)
func WithMethod(m string) Option { return func(r *HttpRequest) { r.Method = m } }func WithHeader(k, v string) Option { return func(r *HttpRequest) { r.Headers[k] = v }}
func NewRequest(url string, opts ...Option) *HttpRequest { r := &HttpRequest{URL: url, Method: "GET", Headers: map[string]string{}, Timeout: 30 * time.Second} for _, opt := range opts { opt(r) } return r}data class HttpRequest( var url: String = "", var method: String = "GET", var headers: MutableMap<String, String> = mutableMapOf(), var body: String? = null, var timeout: Long = 30_000)
fun main() { // apply {} configures the object and returns it -- acts as a builder val request = HttpRequest().apply { url = "https://api.example.com/users" method = "POST" headers["Content-Type"] = "application/json" body = """{"name":"Alice"}""" timeout = 5_000 }}
// For immutable objects, use copy() with named argumentsdata class ImmutableRequest( val url: String, val method: String = "GET", val headers: Map<String, String> = emptyMap(), val body: String? = null, val timeout: Long = 30_000)
fun immutableBuilderDemo() { val base = ImmutableRequest(url = "https://api.example.com") val post = base.copy( method = "POST", headers = base.headers + ("Content-Type" to "application/json"), body = """{"name":"Alice"}""" )}Key differences:
- Kotlin eliminates the builder class entirely using
apply {}. The builder pattern is a “language feature” in Kotlin, not a design pattern you implement. - For immutable objects,
data class+copy()with named arguments provides a functional builder. - TS needs a separate builder class with fluent methods.
- Go uses functional options (closures) which is elegant but more verbose than Kotlin.
Strategy: function types
Section titled “Strategy: function types”// TS can use function types directly (like Kotlin)type PricingStrategy = (basePrice: number, quantity: number) => number;
const standardPricing: PricingStrategy = (price, qty) => price * qty;const bulkPricing: PricingStrategy = (price, qty) => qty >= 10 ? price * qty * 0.9 : price * qty;
function calculateTotal(price: number, qty: number, strategy: PricingStrategy): number { return strategy(price, qty);}type PricingStrategy func(basePrice float64, quantity int) float64
func standardPricing(price float64, qty int) float64 { return price * float64(qty)}
func bulkPricing(price float64, qty int) float64 { if qty >= 10 { return price * float64(qty) * 0.9 } return price * float64(qty)}// Strategy pattern = just a function type. No interface needed.typealias PricingStrategy = (basePrice: Double, quantity: Int) -> Double
val standardPricing: PricingStrategy = { price, qty -> price * qty }
val bulkPricing: PricingStrategy = { price, qty -> if (qty >= 10) price * qty * 0.9 else price * qty}
class OrderProcessor(private val pricing: PricingStrategy) { fun calculateTotal(basePrice: Double, quantity: Int): Double = pricing(basePrice, quantity)}
fun main() { val bulk = OrderProcessor(bulkPricing) println(bulk.calculateTotal(10.0, 15)) // 135.0
// Strategy can be swapped at runtime with an inline lambda val custom = OrderProcessor { price, qty -> if (qty > 100) price * qty * 0.7 else price * qty }}Key differences:
- All three languages support first-class functions, so the strategy pattern is just “pass a function.”
- Kotlin’s
typealiasmakes function type signatures readable without defining an interface (identical to TS’stypeand Go’stypefor function types). - In Java, you would need to create a
Strategyinterface. Kotlin makes this unnecessary.
Observer: delegated properties (observable)
Section titled “Observer: delegated properties (observable)”Kotlin’s Delegates.observable and Delegates.vetoable build the observer pattern into a
property.
import kotlin.properties.Delegates
class UserProfile { // observable -- fires callback on every change var name: String by Delegates.observable("Unknown") { prop, old, new -> println("${prop.name} changed: '$old' -> '$new'") }
// vetoable -- callback can reject the change var age: Int by Delegates.vetoable(0) { prop, old, new -> new in 0..150 // return true to accept, false to reject }
// Custom observable with listener list var email: String = "" set(value) { val old = field field = value listeners.forEach { it(old, value) } }
private val listeners = mutableListOf<(String, String) -> Unit>()
fun onEmailChanged(listener: (old: String, new: String) -> Unit) { listeners.add(listener) }}Decorator: class delegation with by
Section titled “Decorator: class delegation with by”The decorator pattern wraps an object to add behavior. Kotlin’s by delegation makes this
a one-liner instead of forwarding every method.
interface HttpClient { get(url: string): Promise<string>; post(url: string, body: string): Promise<string>;}
class BasicHttpClient implements HttpClient { async get(url: string): Promise<string> { return `GET ${url}`; } async post(url: string, body: string): Promise<string> { return `POST ${url}`; }}
// Decorator -- must manually forward every methodclass LoggingHttpClient implements HttpClient { constructor(private delegate: HttpClient) {}
async get(url: string): Promise<string> { console.log(`GET ${url}`); return this.delegate.get(url); }
async post(url: string, body: string): Promise<string> { console.log(`POST ${url}`); return this.delegate.post(url, body); }}interface HttpClient { fun get(url: String): String fun post(url: String, body: String): String fun delete(url: String): String}
class BasicHttpClient : HttpClient { override fun get(url: String) = "GET $url -> 200 OK" override fun post(url: String, body: String) = "POST $url -> 201 Created" override fun delete(url: String) = "DELETE $url -> 204 No Content"}
// Decorator: `by client` forwards ALL methods, override only what you want to decorateclass LoggingHttpClient(private val client: HttpClient) : HttpClient by client { override fun get(url: String): String { println("[LOG] GET $url") return client.get(url) }
override fun post(url: String, body: String): String { println("[LOG] POST $url (${body.length} bytes)") return client.post(url, body) }
// delete() is automatically forwarded -- no boilerplate needed}
class RetryHttpClient( private val client: HttpClient, private val maxRetries: Int = 3) : HttpClient by client {
override fun get(url: String): String { repeat(maxRetries) { attempt -> try { return client.get(url) } catch (e: Exception) { println("[RETRY] Attempt ${attempt + 1} failed: ${e.message}") } } throw RuntimeException("All $maxRetries retries failed for GET $url") }}
fun main() { // Stack decorators val client: HttpClient = LoggingHttpClient(RetryHttpClient(BasicHttpClient())) println(client.get("https://api.example.com/users")) println(client.delete("https://api.example.com/users/1")) // no log -- not overridden}Decorators compose naturally — stacking LoggingHttpClient, RetryHttpClient, and
BasicHttpClient builds a layered pipeline where each layer adds one concern:
flowchart LR L["LoggingHttpClient"] --> R["RetryHttpClient"] R --> B["BasicHttpClient"] B -->|"HTTP response"| R R -->|"retried response"| L
Key differences:
- Kotlin
bygenerates all forwarding methods at compile time. If the interface adds new methods, the decorator automatically forwards them — no code changes needed. - TS/Go require you to manually forward every method. If the interface changes, you must update every decorator.
- Kotlin decorators compose naturally: stack them with nested constructors.
Practice
Section titled “Practice”Put these concepts to work — generics and variance in one exercise, Java interop in the other.