Skip to content

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.

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);

Key differences:

  • Kotlin’s primary constructor declares properties directly in the class header — no repetitive this.x = x assignments.
  • val = read-only property, var = mutable property. This replaces TS’s readonly.
  • Go has no constructors at all. Kotlin’s primary constructor is more concise than both TS constructors and Go’s NewXxx pattern.
  • No new keyword in Kotlin (same as Go, unlike TS/Java).

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();
}
}

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 overloads
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 ?? "Unknown";
this.email = email ?? `${this.name}@default.com`;
this.age = age ?? 0;
}
}

Key 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++;
}
}

Key differences:

  • In Kotlin, field is 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 in init blocks and property initializers.
  • Go has no properties — only exported/unexported fields with explicit getter methods.
  • Kotlin’s private set gives you read-only from outside, mutable inside — cleaner than TS’s separate _field + getter pattern.
ModifierKotlinTypeScriptGo
publicVisible everywhere (default)public (default)Exported (capitalized name)
privateVisible in same file/classprivateunexported (lowercase name)
protectedVisible in class + subclassesprotectedN/A
internalVisible in same moduleN/APackage-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 is public. Go’s default is unexported.
  • Kotlin’s internal means “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-private visibility (Java’s default). Use internal instead.

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}`);
}
}

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 the implements keyword.
  • 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 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)
Use an interface whenUse 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 stateYou have a base implementation (template method)
Examples: Serializable, Comparable, LoggerExamples: Repository base, HttpHandler base

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 interfaces
data 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 interfaces
type CacheableSerializable interface {
Cacheable
Serializable
}
// A struct implicitly satisfies both if it has all methods
func (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 super implementation to call.

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
}

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 default
class Animal {
constructor(protected name: string) {}
speak(): string {
return `${this.name} makes a sound`;
}
}
class Dog extends Animal {
speak(): string {
return `${this.name} barks`;
}
}

Key differences:

  • Kotlin: classes and methods are final by default. This prevents the “fragile base class” problem. You must deliberately design for inheritance with open.
  • 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() shadows Animal.Speak() — it does not participate in virtual dispatch.
  • Kotlin’s override keyword is mandatory (unlike TS where it is optional).

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 union
type 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
}
}

Key differences:

  • Kotlin sealed classes are truly closed — the compiler enforces exhaustiveness in when expressions. If you add a new subclass, every when that handles the sealed type gets a compile error until you handle the new case.
  • TS discriminated unions provide similar exhaustiveness, but only with the never trick and only for switch on a discriminant property.
  • Go has no sealed types — anyone can implement any interface. Type switches need a default branch.
  • 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 method
class 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}`);
}
}

Key differences:

  • Kotlin by generates 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.

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 implementations
class 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 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>

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 it as the implicit lambda parameter name (like $0 in 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 covariant
animals.push(new Cat("Whiskers")); // Compiles! But now dogs contains a Cat
console.log(dogs[1]); // Cat at runtime -- type system lied

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, covariant
val dogs: List<Dog> = listOf(Dog("Rex"), Dog("Buddy"))
val animals: List<Animal> = dogs // OK! List is covariant (out E)
// MutableList<E> -- read-write, invariant
val mutableDogs: MutableList<Dog> = mutableListOf(Dog("Rex"))
// val mutableAnimals: MutableList<Animal> = mutableDogs // COMPILE ERROR -- invariant

Key differences:

  • Kotlin has declaration-site variance (out/in on 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). Kotlin in = Java ? super T (contravariant). Kotlin’s syntax is much cleaner.

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 it
fun 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 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 intersection
function process<T extends Serializable & Comparable<T>>(item: T): string {
return item.serialize();
}

Key differences:

  • Kotlin uses : for single bounds, where for multiple bounds.
  • TS uses extends for bounds, & for intersection types.
  • Go uses type constraints (interface-based), with ~ for underlying types.
  • Kotlin’s where clause is cleaner than Java’s verbose bounds syntax.

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
}

Key differences:

  • Kotlin <*> is like TS <unknown> — safe but restrictive.
  • Java equivalent is <?>.
  • Go’s any is more like TS’s any — it loses all type information.
  • Kotlin <*> interacts with variance: List<*> = List<out Any?> (you can read Any? but cannot add anything).

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 erased
function isType<T>(value: any): value is T {
// Cannot check T at runtime
return true; // useless
}
// You must pass a constructor/class reference
function isTypeChecked<T>(value: any, ctor: new (...args: any[]) => T): value is T {
return value instanceof ctor;
}

A practical use: a type-safe service locator that needs no Class<T> parameter and no string keys.

ServiceLocator.kt
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:

  • reified is Kotlin-only. It eliminates the need for Class<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 reified equivalent.
  • reified only works with inline functions because the compiler substitutes the actual type at each call site.

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 possible
fun <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.

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.

JavaUserService.java
// Java code -- no nullability annotations
public 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 Java
fun 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.

UserUtils.kt
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"
}
}
JavaCaller.java
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:

AnnotationPurposeWithout it (from Java)
@JvmStaticMake companion member a real Java staticFoo.Companion.method()
@JvmFieldExpose property as a public fieldFoo.Companion.getPROP()
@JvmOverloadsGenerate overloads for default paramsMust pass all params
@JvmNameChange generated method/class nameUses Kotlin-generated name
@file:JvmNameChange filename-based class nameFileNameKt.function()
@ThrowsDeclare checked exceptionsJava cannot catch 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 properly
try {
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());
}

BigDecimal — precise decimal arithmetic. Never use Double for money; BigDecimal is exact, and Kotlin operator overloading lets you use +, -, * directly.

import java.math.BigDecimal
import 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.DateTimeFormatter
import 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"
}

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
}

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.Callable
import 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 interface keyword creates a Kotlin SAM interface that also supports lambda conversion.

The JVM ecosystem is the real payoff of interop — Jackson, Guava, and Apache Commons all work seamlessly from Kotlin.

Jackson (JSON serialization)
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import 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)
}
Guava & Apache Commons
import com.google.common.base.Splitter
import com.google.common.cache.CacheBuilder
import org.apache.commons.lang3.StringUtils
import 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
}

Kotlin’s language features make many classic design patterns trivial or unnecessary. Here is how each pattern maps across TypeScript, Go, and Kotlin.

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();

Key differences:

  • Kotlin object is a first-class language feature. Thread-safe. Lazy. Zero boilerplate.
  • TS requires the manual singleton pattern with a private constructor.
  • Go requires sync.Once for thread-safe singleton initialization.
  • Kotlin object compiles to a Java class with an INSTANCE static field.
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);
}
}

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();

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.
// 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);
}

Key differences:

  • All three languages support first-class functions, so the strategy pattern is just “pass a function.”
  • Kotlin’s typealias makes function type signatures readable without defining an interface (identical to TS’s type and Go’s type for function types).
  • In Java, you would need to create a Strategy interface. 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)
}
}

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 method
class 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);
}
}

Decorators compose naturally — stacking LoggingHttpClient, RetryHttpClient, and BasicHttpClient builds a layered pipeline where each layer adds one concern:

Stacked HTTP client decorators
Rendering diagram…

Key differences:

  • Kotlin by generates 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.

Put these concepts to work — generics and variance in one exercise, Java interop in the other.