Advanced Kotlin
This is where Kotlin stops looking like “a nicer Java” and starts showing what
makes it uniquely expressive: type-safe builders, first-class delegation,
inline/reified generics that beat JVM type erasure, context parameters, and
compile-time code generation with KSP. These are the features you reach for when
you want libraries that read like configuration and abstractions that cost
nothing at runtime — things you can only fake in TypeScript and Go.
Type-Safe Builders and DSLs
Section titled “Type-Safe Builders and DSLs”A Domain-Specific Language (DSL) is a mini-language tailored to a problem domain. Kotlin’s combination of lambda-with-receiver, extension functions, and trailing lambda syntax makes it the best mainstream language for internal DSLs.
The concepts behind Kotlin DSLs
Section titled “The concepts behind Kotlin DSLs”Three Kotlin features combine to enable DSLs: extension functions add methods to
existing types, a lambda with receiver makes this refer to a receiver object,
and trailing lambda syntax moves the last lambda param outside the parentheses.
// 1. Extension functions — add methods to existing typesfun String.shout() = this.uppercase() + "!!!""hello".shout() // "HELLO!!!"
// 2. Lambda with receiver — a lambda where `this` refers to a receiver objectfun buildString(block: StringBuilder.() -> Unit): String { val sb = StringBuilder() sb.block() // invoke the lambda with sb as receiver return sb.toString()}
val result = buildString { append("Hello, ") // `this` is StringBuilder append("World!")}// result = "Hello, World!"
// 3. Trailing lambda syntax — last lambda param goes outside parentheses// Combined with the above, this gives us "block" syntax that looks like new keywordsThe key trick is the receiver type on the lambda parameter — StringBuilder.() -> Unit
rather than () -> Unit. That single .() is what lets the block body call
StringBuilder methods as if this were implicit.
Cross-language comparison: builder patterns
Section titled “Cross-language comparison: builder patterns”How TS, Go, and Kotlin each approach “configure an object with a block”:
// TS has no native DSL support. You fake it with method chaining:class QueryBuilder { private parts: string[] = [];
select(columns: string): this { this.parts.push(`SELECT ${columns}`); return this; }
from(table: string): this { this.parts.push(`FROM ${table}`); return this; }
where(condition: string): this { this.parts.push(`WHERE ${condition}`); return this; }
build(): string { return this.parts.join(" "); }}
const query = new QueryBuilder() .select("name, email") .from("users") .where("active = true") .build();// Go has no receiver lambdas. The closest pattern is functional options:type ServerConfig struct { Host string Port int TLS bool}
type Option func(*ServerConfig)
func WithHost(host string) Option { return func(c *ServerConfig) { c.Host = host }}
func WithPort(port int) Option { return func(c *ServerConfig) { c.Port = port }}
func WithTLS() Option { return func(c *ServerConfig) { c.TLS = true }}
func NewServer(opts ...Option) *Server { cfg := &ServerConfig{Host: "localhost", Port: 8080} for _, opt := range opts { opt(cfg) } // ...}
server := NewServer(WithHost("0.0.0.0"), WithPort(9090), WithTLS())// Kotlin DSLs read like configuration files but are fully type-checked:val server = server { host("0.0.0.0") port(9090) tls { certFile("/etc/ssl/cert.pem") keyFile("/etc/ssl/key.pem") } routing { get("/health") { respond("OK") } get("/users") { respond(userService.findAll()) } }}Key differences:
- TypeScript relies on method chaining (fluent builders) — no nesting, no compile-time scope control.
- Go uses functional options — type-safe but no nesting or block syntax.
- Kotlin DSLs support arbitrarily deep nesting, compile-time type checking at every level, and IDE autocompletion inside every block.
Building your first DSL: HTML builder
Section titled “Building your first DSL: HTML builder”A self-contained DSL: each tag class extends a base Tag, and nesting methods like
head { ... } and body { ... } take a Head.() -> Unit / Body.() -> Unit
receiver lambda.
// Step 1: Define the domain model@DslMarkerannotation class HtmlDsl
@HtmlDslopen class Tag(var name: String) { val children = mutableListOf<Tag>() val attributes = mutableMapOf<String, String>() protected var textContent: String = ""
fun attribute(key: String, value: String) { attributes[key] = value }
override fun toString(): String { val attrStr = if (attributes.isEmpty()) "" else attributes.entries.joinToString(" ", prefix = " ") { "${it.key}=\"${it.value}\"" } val childStr = children.joinToString("\n") { " $it" } val content = if (textContent.isNotEmpty()) textContent else childStr return "<$name$attrStr>\n$content\n</$name>" }}
// Step 2: Create specific tag classes@HtmlDslclass Html : Tag("html") { fun head(block: Head.() -> Unit) { val head = Head() head.block() children.add(head) }
fun body(block: Body.() -> Unit) { val body = Body() body.block() children.add(body) }}
@HtmlDslclass Head : Tag("head") { fun title(text: String) { val t = Tag("title") t.textContent = text // direct access within same module children.add(t) }
fun meta(charset: String) { val m = Tag("meta") m.attribute("charset", charset) children.add(m) }}
@HtmlDslclass Body : Tag("body") { fun h1(text: String) { val h = Tag("h1") h.textContent = text children.add(h) }
fun p(text: String) { val p = Tag("p") p.textContent = text children.add(p) }
fun div(block: Body.() -> Unit) { val d = Body() // reuse Body for nested blocks d.name = "div" // would need to be mutable — see note d.block() children.add(d) }
fun a(href: String, text: String) { val a = Tag("a") a.attribute("href", href) a.textContent = text children.add(a) }
operator fun String.unaryPlus() { val t = Tag("span") t.textContent = this children.add(t) }}
// Step 3: Top-level DSL entry pointfun html(block: Html.() -> Unit): Html { val html = Html() html.block() return html}
// Step 4: Use it!fun main() { val page = html { head { meta("UTF-8") title("My Page") } body { h1("Welcome") p("This is a type-safe HTML builder.") a("https://kotlinlang.org", "Kotlin") } } println(page)}@DslMarker — preventing scope leakage
Section titled “@DslMarker — preventing scope leakage”Without @DslMarker, inner lambdas can access outer receivers, leading to bugs.
Inside head { ... }, the Html.body() method is still in implicit scope, so
body { } compiles even though it makes no sense inside <head>:
// WITHOUT @DslMarker — dangerous scope leakagehtml { head { // BUG: `body {}` is accessible here because Html's body() is in scope! body { } // compiles but makes no sense inside <head> }}
// WITH @DslMarker — compiler error@DslMarkerannotation class HtmlDsl
@HtmlDslclass Html { /* ... */ }
@HtmlDslclass Head { /* ... */ }
html { head { body { } // ERROR: 'fun body(...)' can't be called in this context // by implicit receiver. Use the explicit receiver if needed. }}How @DslMarker works:
- You create an annotation marked with
@DslMarker. - You apply it to all DSL classes.
- The compiler restricts implicit access to only the nearest receiver.
- To access an outer receiver, you must use a labeled reference:
this@html.body { }.
@DslMarkerannotation class HtmlDsl
@HtmlDslclass Html { /* ... */ }
@HtmlDslclass Head { /* ... */ }
@HtmlDslclass Body { /* ... */ }
html { // Inside here, `this` is Html head { // Inside here, `this` is Head // Html's methods are NOT implicitly available // To explicitly call Html methods: this@html.body { } // OK — explicit outer receiver }}Real-world Kotlin DSLs
Section titled “Real-world Kotlin DSLs”Kotlin’s standard library and ecosystem are full of DSLs — and you’ve already been using several of them in this course (your Gradle build files are DSLs).
// 1. kotlinx.html — production HTML DSLimport kotlinx.html.*import kotlinx.html.stream.createHTML
val html = createHTML().html { body { div { classes = setOf("container") h1 { +"Welcome" } p { +"Type-safe HTML" } } }}
// 2. Ktor routing — the DSL you'll replicate in the exerciserouting { route("/api/v1") { get("/users") { call.respond(userService.findAll()) } post("/users") { val user = call.receive<CreateUserRequest>() call.respond(HttpStatusCode.Created, userService.create(user)) } }}
// 3. Gradle Kotlin DSL — your build files are DSLs!plugins { kotlin("jvm") version "2.0.0" application}
dependencies { implementation("io.ktor:ktor-server-core:3.0.0") testImplementation(kotlin("test"))}
// 4. Exposed SQL DSLobject Users : Table("users") { val id = integer("id").autoIncrement() val name = varchar("name", 255) val email = varchar("email", 255) override val primaryKey = PrimaryKey(id)}
transaction { Users.select { Users.name like "%kotlin%" } .forEach { println(it[Users.email]) }}
// 5. Kotest — test DSLclass UserServiceTest : StringSpec({ "should create user" { val user = userService.create("Alice") user.name shouldBe "Alice" }
"should reject empty name" { shouldThrow<IllegalArgumentException> { userService.create("") } }})Building a configuration DSL
Section titled “Building a configuration DSL”A practical example — a type-safe application configuration DSL. Each nested block
(server { }, cors { }, database { }) creates a config object and applies the
block to it with apply, so the whole thing reads like YAML but is type-checked.
@DslMarkerannotation class ConfigDsl
@ConfigDslclass AppConfig { var name: String = "" var version: String = "1.0.0"
private var _server: ServerConfig? = null private var _database: DatabaseConfig? = null private var _logging: LoggingConfig? = null
val server: ServerConfig get() = _server ?: error("Server not configured") val database: DatabaseConfig get() = _database ?: error("Database not configured") val logging: LoggingConfig get() = _logging ?: error("Logging not configured")
fun server(block: ServerConfig.() -> Unit) { _server = ServerConfig().apply(block) }
fun database(block: DatabaseConfig.() -> Unit) { _database = DatabaseConfig().apply(block) }
fun logging(block: LoggingConfig.() -> Unit) { _logging = LoggingConfig().apply(block) }}
@ConfigDslclass ServerConfig { var host: String = "localhost" var port: Int = 8080 var ssl: Boolean = false
private var _cors: CorsConfig? = null val cors: CorsConfig? get() = _cors
fun cors(block: CorsConfig.() -> Unit) { _cors = CorsConfig().apply(block) }}
@ConfigDslclass CorsConfig { val allowedOrigins = mutableListOf<String>() val allowedMethods = mutableListOf<String>()
fun origin(url: String) { allowedOrigins.add(url) } fun method(m: String) { allowedMethods.add(m) }}
@ConfigDslclass DatabaseConfig { var url: String = "" var username: String = "" var password: String = "" var maxPoolSize: Int = 10 var minIdle: Int = 2}
@ConfigDslclass LoggingConfig { var level: String = "INFO" var format: String = "json" var file: String? = null}
// Top-level entry pointfun appConfig(block: AppConfig.() -> Unit): AppConfig = AppConfig().apply(block)
// Usage — reads like YAML but is type-checked Kotlinfun main() { val config = appConfig { name = "my-service" version = "2.1.0"
server { host = "0.0.0.0" port = 9090 ssl = true
cors { origin("https://example.com") origin("https://app.example.com") method("GET") method("POST") method("PUT") } }
database { url = "jdbc:postgresql://localhost:5432/mydb" username = "app_user" password = System.getenv("DB_PASSWORD") ?: "dev-password" maxPoolSize = 20 }
logging { level = "DEBUG" format = "json" file = "/var/log/my-service.log" } }
println("Starting ${config.name} v${config.version}") println("Server: ${config.server.host}:${config.server.port}") println("Database: ${config.database.url}") println("Log level: ${config.logging.level}")}Delegation
Section titled “Delegation”Kotlin has first-class support for the delegation pattern — both for classes and properties. This eliminates the boilerplate that TS and Go developers write by hand.
Class delegation with by
Section titled “Class delegation with by”The problem: you want to compose behavior from multiple interfaces without deep inheritance hierarchies.
interface Logger { log(message: string): void; error(message: string): void;}
interface Metrics { increment(counter: string): void; gauge(name: string, value: number): void;}
class ConsoleLogger implements Logger { log(message: string) { console.log(message); } error(message: string) { console.error(message); }}
class PrometheusMetrics implements Metrics { increment(counter: string) { /* ... */ } gauge(name: string, value: number) { /* ... */ }}
// Manual delegation — you must write every methodclass Service implements Logger, Metrics { constructor( private logger: Logger = new ConsoleLogger(), private metrics: Metrics = new PrometheusMetrics() ) {}
// Tedious: forward every method log(message: string) { this.logger.log(message); } error(message: string) { this.logger.error(message); } increment(counter: string) { this.metrics.increment(counter); } gauge(name: string, value: number) { this.metrics.gauge(name, value); }}type Logger interface { Log(message string) Error(message string)}
type Metrics interface { Increment(counter string) Gauge(name string, value float64)}
// Go embedding — methods are promoted automaticallytype Service struct { Logger // embedded Metrics // embedded}
// Usagesvc := Service{ Logger: &ConsoleLogger{}, Metrics: &PrometheusMetrics{},}svc.Log("hello") // delegated to ConsoleLoggersvc.Increment("req") // delegated to PrometheusMetricsinterface Logger { fun log(message: String) fun error(message: String)}
interface Metrics { fun increment(counter: String) fun gauge(name: String, value: Double)}
class ConsoleLogger : Logger { override fun log(message: String) = println("[LOG] $message") override fun error(message: String) = System.err.println("[ERR] $message")}
class PrometheusMetrics : Metrics { override fun increment(counter: String) { /* ... */ } override fun gauge(name: String, value: Double) { /* ... */ }}
// `by` generates all the forwarding methods at compile timeclass Service( logger: Logger = ConsoleLogger(), metrics: Metrics = PrometheusMetrics()) : Logger by logger, Metrics by metrics {
// You can OVERRIDE specific methods if needed override fun log(message: String) { println("[SERVICE] $message") // custom behavior }}
fun main() { val svc = Service() svc.log("Starting") // uses overridden version svc.error("Something bad") // delegates to ConsoleLogger svc.increment("requests") // delegates to PrometheusMetrics}Key differences:
- TypeScript requires writing every forwarding method manually.
- Go embedding auto-promotes methods but doesn’t implement interfaces explicitly.
- Kotlin
byis the best of both: zero boilerplate, explicit interface implementation, and you can override individual methods.
Decorator pattern with class delegation
Section titled “Decorator pattern with class delegation”The by delegate clause forwards everything by default, so a decorator only needs
to override the methods it wants to wrap.
interface HttpClient { suspend fun get(url: String): String suspend fun post(url: String, body: String): String}
class RealHttpClient : HttpClient { override suspend fun get(url: String): String { /* real HTTP */ return "" } override suspend fun post(url: String, body: String): String { /* real HTTP */ return "" }}
// Logging decorator — delegates everything, wraps specific methodsclass LoggingHttpClient( private val delegate: HttpClient) : HttpClient by delegate {
override suspend fun get(url: String): String { println("GET $url") val result = delegate.get(url) println("GET $url -> ${result.length} bytes") return result }
override suspend fun post(url: String, body: String): String { println("POST $url (${body.length} bytes)") val result = delegate.post(url, body) println("POST $url -> ${result.length} bytes") return result }}
// Retry decorator — wraps all methodsclass RetryHttpClient( private val delegate: HttpClient, private val maxRetries: Int = 3) : HttpClient by delegate {
override suspend fun get(url: String): String = retry { delegate.get(url) } override suspend fun post(url: String, body: String): String = retry { delegate.post(url, body) }
private suspend fun <T> retry(block: suspend () -> T): T { var lastException: Exception? = null repeat(maxRetries) { try { return block() } catch (e: Exception) { lastException = e } } throw lastException!! }}
// Stack decoratorsfun main() { val client: HttpClient = LoggingHttpClient( RetryHttpClient( RealHttpClient(), maxRetries = 3 ) )}Property delegation
Section titled “Property delegation”Kotlin lets you delegate the getter and setter of a property to another object. The standard library provides several built-in delegates.
lazy — compute once on first access
Section titled “lazy — compute once on first access”lazy initializes on first read and is thread-safe by default:
class ExpensiveService { val connection: DatabaseConnection by lazy { println("Creating connection...") DatabaseConnection("jdbc:postgresql://localhost/mydb") }
val config: Map<String, String> by lazy { println("Loading config from disk...") loadConfigFromFile("/etc/app.conf") }}
fun main() { val svc = ExpensiveService() println("Service created") // nothing loaded yet println(svc.connection) // "Creating connection..." then value println(svc.connection) // cached — no message}Thread-safety modes let you trade safety for speed:
// Default: synchronized (thread-safe, some overhead)val a by lazy { compute() }
// Publication: multiple threads may compute, but all see same resultval b by lazy(LazyThreadSafetyMode.PUBLICATION) { compute() }
// None: no synchronization (fastest, use in single-threaded context)val c by lazy(LazyThreadSafetyMode.NONE) { compute() }The TypeScript equivalent is a memoized getter — same idea, much more boilerplate (and no thread-safety concern in single-threaded JS):
class ExpensiveService { private _connection?: DatabaseConnection;
get connection(): DatabaseConnection { if (!this._connection) { this._connection = new DatabaseConnection("jdbc:..."); } return this._connection; }}observable — react to property changes
Section titled “observable — react to property changes”import kotlin.properties.Delegates
class UserPreferences { var theme: String by Delegates.observable("light") { prop, old, new -> println("Theme changed: $old -> $new") saveToStorage(prop.name, new) }
var fontSize: Int by Delegates.observable(14) { _, old, new -> println("Font size: $old -> $new") }}
fun main() { val prefs = UserPreferences() prefs.theme = "dark" // "Theme changed: light -> dark" prefs.fontSize = 18 // "Font size: 14 -> 18"}vetoable — reject invalid values
Section titled “vetoable — reject invalid values”import kotlin.properties.Delegates
class Account { var balance: Double by Delegates.vetoable(0.0) { _, old, new -> if (new < 0) { println("REJECTED: balance cannot be negative (attempted: $new)") false // reject the change } else { println("Balance: $old -> $new") true // accept the change } }}
fun main() { val account = Account() account.balance = 100.0 // "Balance: 0.0 -> 100.0" account.balance = -50.0 // "REJECTED: balance cannot be negative" println(account.balance) // 100.0 (unchanged)}Map delegation — dynamic properties
Section titled “Map delegation — dynamic properties”Properties can be backed by a Map, where the keys match the property names — great
for config or JSON-shaped data:
class User(map: Map<String, Any?>) { val name: String by map val age: Int by map val email: String by map}
fun main() { val userData = mapOf( "name" to "Alice", "age" to 30, "email" to "alice@example.com" )
val user = User(userData) println("${user.name}, ${user.age}, ${user.email}") // Alice, 30, alice@example.com}
// Mutable version — assignments write back into the mapclass MutableUser(map: MutableMap<String, Any?>) { var name: String by map var age: Int by map var email: String by map}
fun mutableExample() { val data = mutableMapOf<String, Any?>( "name" to "Bob", "age" to 25, "email" to "bob@example.com" )
val user = MutableUser(data) user.name = "Robert" println(data["name"]) // "Robert" — the map is updated!}Custom property delegates
Section titled “Custom property delegates”You can create your own delegates by implementing ReadOnlyProperty or
ReadWriteProperty. A delegate just needs getValue (and, for read-write,
setValue) taking a thisRef and a KProperty<*>:
import kotlin.properties.ReadWritePropertyimport kotlin.reflect.KProperty
// Custom delegate: trim whitespace automaticallyclass TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) { this.value = value.trim() }}
// Helper function for cleaner syntaxfun trimmed(initial: String = "") = TrimmedString(initial)
class UserForm { var name: String by trimmed() var email: String by trimmed() var bio: String by trimmed()}
fun main() { val form = UserForm() form.name = " Alice " form.email = " alice@example.com " println("'${form.name}'") // 'Alice' println("'${form.email}'") // 'alice@example.com'}A more practical example — an environment-variable delegate that reads from
System.getenv, defaulting the variable name to the uppercased property name:
import kotlin.properties.ReadOnlyPropertyimport kotlin.reflect.KProperty
class EnvironmentVariable( private val name: String? = null, private val default: String? = null, private val required: Boolean = true) : ReadOnlyProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String { val envName = name ?: property.name.uppercase().replace('.', '_') return System.getenv(envName) ?: default ?: if (required) error("Required environment variable '$envName' is not set") else "" }}
fun env(name: String? = null, default: String? = null, required: Boolean = true) = EnvironmentVariable(name, default, required)
// Usage — clean, declarative configobject AppConfig { val databaseUrl: String by env("DATABASE_URL") val databaseUser: String by env("DATABASE_USER", default = "postgres") val databasePassword: String by env("DATABASE_PASSWORD") val serverPort: String by env("SERVER_PORT", default = "8080") val logLevel: String by env("LOG_LEVEL", default = "INFO") val debugMode: String by env("DEBUG_MODE", default = "false", required = false)}
fun main() { println("Port: ${AppConfig.serverPort}") println("Log level: ${AppConfig.logLevel}")}A cached/expiring-value delegate adds a TTL on top of a loader function:
import kotlin.properties.ReadOnlyPropertyimport kotlin.reflect.KProperty
class CachedValue<T>( private val ttlMillis: Long, private val loader: () -> T) : ReadOnlyProperty<Any?, T> {
private var cachedValue: T? = null private var lastLoadTime: Long = 0
override fun getValue(thisRef: Any?, property: KProperty<*>): T { val now = System.currentTimeMillis() if (cachedValue == null || (now - lastLoadTime) > ttlMillis) { cachedValue = loader() lastLoadTime = now } @Suppress("UNCHECKED_CAST") return cachedValue as T }}
fun <T> cached(ttlMillis: Long, loader: () -> T) = CachedValue(ttlMillis, loader)
// Usageclass FeatureFlagService { val flags: Map<String, Boolean> by cached(ttlMillis = 60_000) { println("Loading feature flags from remote...") mapOf("new-ui" to true, "beta-feature" to false) }}How delegation compiles
Section titled “How delegation compiles”Understanding the bytecode helps you reason about performance. Class delegation stores the delegate as a field and generates plain forwarding methods — no runtime reflection, no virtual dispatch overhead beyond the interface call:
// SOURCE:class Service(logger: Logger) : Logger by logger
// WHAT THE COMPILER GENERATES (approximately):class Service(private val delegate_logger: Logger) : Logger { override fun log(message: String) = delegate_logger.log(message) override fun error(message: String) = delegate_logger.error(message)}Property delegation desugars to a hidden $delegate field plus a getter that reads
through it:
// SOURCE:val name: String by lazy { "Alice" }
// WHAT THE COMPILER GENERATES (approximately):private val nameDelegate = lazy { "Alice" }val name: String get() = nameDelegate.value
// The Lazy<T> instance handles synchronization.Inline Functions and Reified Generics
Section titled “Inline Functions and Reified Generics”The problem with JVM generics (type erasure)
Section titled “The problem with JVM generics (type erasure)”The JVM erases generic type parameters at runtime — inherited from Java. A bare
value is T won’t compile because T is gone after compilation:
// This DOES NOT WORK:fun <T> isInstanceOf(value: Any): Boolean { return value is T // ERROR: Cannot check for instance of erased type: T}
// Because after compilation, T is gone:// fun isInstanceOf(value: Any): Boolean {// return value is ??? // T is erased, JVM doesn't know what T was// }TypeScript and Go handle this differently:
// TypeScript has the SAME problem — types are erased at compile timefunction isInstanceOf<T>(value: any): value is T { // No way to check T at runtime — TS types don't exist at runtime return false; // you'd need a runtime type guard}// Go generics also have limitations, but Go has reflect:func isInstanceOf[T any](value any) bool { _, ok := value.(T) // This works in Go! return ok}Inline functions
Section titled “Inline functions”inline tells the compiler to copy the function body into every call site,
eliminating the lambda object allocation:
// Without inline: a lambda object is created on the heapfun measure(block: () -> Unit) { val start = System.nanoTime() block() // block is an object with invoke() method val elapsed = System.nanoTime() - start println("Elapsed: ${elapsed}ns")}
// With inline: the lambda body is pasted directly into the call siteinline fun measureInline(block: () -> Unit) { val start = System.nanoTime() block() // replaced with the actual lambda body at compile time val elapsed = System.nanoTime() - start println("Elapsed: ${elapsed}ns")}
fun main() { // This: measureInline { Thread.sleep(100) }
// Compiles to (roughly): val start = System.nanoTime() Thread.sleep(100) // inlined directly! val elapsed = System.nanoTime() - start println("Elapsed: ${elapsed}ns")}noinline and crossinline
Section titled “noinline and crossinline”When a function is inline you can opt individual lambda params out with noinline
(needed if you store the lambda) or restrict them with crossinline (forbids
non-local returns):
inline fun execute( block: () -> Unit, // will be inlined noinline callback: () -> Unit // will NOT be inlined (kept as object)) { block() // We need `noinline` here because we're storing the lambda: val savedCallback = callback // can't store an inlined lambda // use savedCallback later...}
// crossinline: prevents non-local returns in the lambdainline fun runInThread(crossinline block: () -> Unit) { Thread { block() // without crossinline, `return` here would try to return from // the enclosing function, which is impossible from another thread }.start()}Because inlined lambdas are pasted into the caller, a return inside them returns
from the enclosing function — a non-local return, a unique Kotlin feature:
inline fun repeatUntil(condition: () -> Boolean, block: () -> Unit) { while (true) { block() if (condition()) return // returns from repeatUntil }}
fun findFirst(items: List<String>, target: String): String? { var result: String? = null items.forEach { item -> // forEach is inline if (item == target) { result = item return result // non-local return — exits findFirst! } } return null}Reified generics
Section titled “Reified generics”reified + inline means type parameters survive at runtime, because the type is
substituted at each call site. Now value is T and T::class both work:
// Now this WORKS:inline fun <reified T> isInstanceOf(value: Any): Boolean { return value is T // OK! T is known at compile time at each call site}
fun main() { println(isInstanceOf<String>("hello")) // true println(isInstanceOf<Int>("hello")) // false println(isInstanceOf<String>(42)) // false}Practical uses of reified generics — type-safe JSON parsing, a tiny service locator, logger creation, and filtering by type:
// 1. Type-safe JSON deserializationinline fun <reified T> String.parseJson(): T { val mapper = jacksonObjectMapper() return mapper.readValue(this, T::class.java) // ^^^^^^^^^^^ only possible with reified!}
val user: User = """{"name":"Alice","age":30}""".parseJson()val items: List<Item> = """[{"id":1},{"id":2}]""".parseJson()
// 2. Service locator / simple DIclass ServiceLocator { private val services = mutableMapOf<KClass<*>, Any>()
fun <T : Any> register(clazz: KClass<T>, instance: T) { services[clazz] = instance }
inline fun <reified T : Any> get(): T { return services[T::class] as? T ?: error("No service registered for ${T::class.simpleName}") }}
val locator = ServiceLocator()locator.register(UserService::class, UserServiceImpl())
// Look ma, no class parameter!val userService = locator.get<UserService>()
// 3. Type-safe logger creationinline fun <reified T> logger(): Logger { return LoggerFactory.getLogger(T::class.java)}
class UserController { private val log = logger<UserController>()}
// 4. Filtering collections by type — filterIsInstance uses reified genericsval mixed: List<Any> = listOf(1, "hello", 2.0, "world", 3)val strings: List<String> = mixed.filterIsInstance<String>() // ["hello", "world"]val ints: List<Int> = mixed.filterIsInstance<Int>() // [1, 3]Contracts (experimental)
Section titled “Contracts (experimental)”Contracts let you tell the compiler facts about your function’s behavior — for
example that a true return implies the receiver is non-null, or that a lambda is
called exactly once:
import kotlin.contracts.ExperimentalContractsimport kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)fun String?.isNotNullOrEmpty(): Boolean { contract { returns(true) implies (this@isNotNullOrEmpty != null) } return this != null && this.isNotEmpty()}
fun processName(name: String?) { if (name.isNotNullOrEmpty()) { // Compiler knows name is non-null here because of the contract! println(name.uppercase()) // no ?. needed }}
@OptIn(ExperimentalContracts::class)inline fun <R> executeExactlyOnce(block: () -> R): R { contract { callsInPlace(block, kotlin.contracts.InvocationKind.EXACTLY_ONCE) } return block()}
fun demo() { val x: Int executeExactlyOnce { x = 42 // OK — compiler knows block runs exactly once, so x is definitely initialized } println(x) // OK — x is definitely assigned}Context Receivers / Context Parameters
Section titled “Context Receivers / Context Parameters”The problem
Section titled “The problem”You have functions that need access to “ambient” services (logger, DB connection, transaction, coroutine scope) without passing them explicitly every time. The usual options each have a downside:
// Approach 1: Pass explicitly — verbosefun createUser( name: String, db: Database, logger: Logger, metrics: Metrics, validator: Validator): User { logger.log("Creating user $name") metrics.increment("user.created") validator.validate(name) return db.insert(User(name = name))}
// Approach 2: Global singletons — hard to testfun createUser(name: String): User { GlobalLogger.log("Creating user $name") // untestable GlobalMetrics.increment("user.created") // untestable return GlobalDb.insert(User(name = name)) // untestable}
// Approach 3: DI framework (Spring) — magic annotations@Serviceclass UserService( private val db: Database, // injected private val logger: Logger, // injected private val metrics: Metrics // injected) { fun createUser(name: String): User { /* ... */ }}Cross-language comparison
Section titled “Cross-language comparison”// TS devs use constructor injection or closures:class UserService { constructor( private db: Database, private logger: Logger, private metrics: Metrics ) {}
createUser(name: string): User { this.logger.log(`Creating user ${name}`); this.metrics.increment("user.created"); return this.db.insert({ name }); }}// Go uses context.Context for cross-cutting concerns, but it's untyped:func CreateUser(ctx context.Context, name string) (*User, error) { logger := ctx.Value("logger").(Logger) // runtime type assertion! metrics := ctx.Value("metrics").(Metrics) // runtime type assertion! db := ctx.Value("db").(Database) // runtime type assertion! // No compile-time safety on what's in the context return db.Insert(ctx, &User{Name: name})}// Context receivers provide compile-time checked implicit parameters// Enable with compiler flag: -Xcontext-receiverscontext(Logger, Metrics, Database)fun createUser(name: String): User { // Logger, Metrics, Database are all available as implicit `this` log("Creating user $name") // from Logger context increment("user.created") // from Metrics context return insert(User(name = name)) // from Database context}
// Calling requires all contexts to be in scope:with(logger) { with(metrics) { with(database) { createUser("Alice") // all three contexts are provided } }}Context parameters (Kotlin 2.x direction)
Section titled “Context parameters (Kotlin 2.x direction)”The refined design uses named context(...) parameter syntax:
// Define interfaces for your contextsinterface LoggingContext { fun log(message: String) fun error(message: String)}
interface TransactionContext { fun <T> execute(block: () -> T): T fun rollback()}
// Functions declare what contexts they needcontext(logCtx: LoggingContext)fun processOrder(orderId: String) { logCtx.log("Processing order $orderId") // ...}
context(logCtx: LoggingContext, txCtx: TransactionContext)fun createOrder(items: List<Item>): Order { logCtx.log("Creating order with ${items.size} items") return txCtx.execute { val order = Order(items = items) // save to DB order }}
// Extension functions with contextcontext(logCtx: LoggingContext)fun Order.validate(): Boolean { logCtx.log("Validating order ${this.id}") return this.items.isNotEmpty()}Until context parameters stabilize: extension function pattern
Section titled “Until context parameters stabilize: extension function pattern”A practical approach that works today without experimental flags: declare a
ServiceContext interface holding the ambient services, then write your operations
as extension functions on it and call them inside with(ctx) { ... }:
interface ServiceContext { val logger: Logger val db: Database val metrics: Metrics}
// Extension functions on ServiceContextfun ServiceContext.createUser(name: String): User { logger.info("Creating user $name") metrics.increment("users.created") return db.insert(User(name = name))}
fun ServiceContext.findUser(id: Long): User? { logger.info("Finding user $id") return db.findById(id)}
// Implementationclass ProductionContext( override val logger: Logger, override val db: Database, override val metrics: Metrics) : ServiceContext
// Usagefun main() { val ctx = ProductionContext( logger = Slf4jLogger(), db = PostgresDb(), metrics = PrometheusMetrics() )
with(ctx) { val user = createUser("Alice") val found = findUser(user.id) }}
// Testing — easy to mockclass TestContext : ServiceContext { override val logger = NoOpLogger() override val db = InMemoryDb() override val metrics = NoOpMetrics()}Annotation Processing with KSP
Section titled “Annotation Processing with KSP”What is KSP?
Section titled “What is KSP?”KSP (Kotlin Symbol Processing) is Kotlin’s compile-time code generation tool. It reads your Kotlin source code as a symbol tree and generates new Kotlin/Java files. Compared to the other ecosystems:
| Feature | TypeScript | Go | Kotlin |
|---|---|---|---|
| Mechanism | Decorators + reflect-metadata | go generate + AST parsing | KSP (compiler plugin) |
| Runs at | Runtime (decorators) | Before compile (go generate) | During compilation |
| Type info | Limited runtime reflection | Full AST access | Full symbol + type info |
| Output | Runtime behavior modification | Generated .go files | Generated .kt/.java files |
// TS decorators run at RUNTIME — they modify classes after compilationfunction Entity(tableName: string) { return function (constructor: Function) { Reflect.defineMetadata("tableName", tableName, constructor); };}
function Column(options?: { primary?: boolean }) { return function (target: any, propertyKey: string) { const columns = Reflect.getMetadata("columns", target.constructor) || []; columns.push({ name: propertyKey, ...options }); Reflect.defineMetadata("columns", columns, target.constructor); };}
@Entity("users")class User { @Column({ primary: true }) id!: number;
@Column() name!: string;}//go:generate stringer -type=Statustype Status int
const ( Active Status = iota Inactive Deleted)
// Run `go generate ./...` to produce status_string.go// which contains func (s Status) String() string { ... }// Annotations are just markers — the KSP processor does the work@Entity(tableName = "users")data class User( @PrimaryKey val id: Long, @Column(name = "user_name") val name: String, @Column val email: String)
// KSP generates at compile time:// - UserTable object with column definitions// - UserDao interface with CRUD operations// - UserMapper for ResultSet -> User conversion// No runtime reflection needed!How KSP works
Section titled “How KSP works”KSP runs as a plugin inside the Kotlin compiler: it reads the symbol tree, hands it
to your processor, which writes new .kt files that get compiled alongside the
originals.
flowchart TB A["Your code (.kt files)"] --> B["Kotlin compiler"] B --> C["KSP plugin runs"] C -->|"reads symbols: classes, functions, properties, annotations"| D["Your KSP processor"] D --> E["Generated .kt files"] A --> F["Compile generated files together with original code"] E --> F F --> G["Final .class files"]
KSP processor anatomy
Section titled “KSP processor anatomy”A processor implements SymbolProcessor.process, finds annotated symbols via the
Resolver, and writes files through the CodeGenerator. Here a processor reads
every @AutoBuilder class and emits a matching builder:
// build.gradle.kts for the processor moduleplugins { kotlin("jvm")}
dependencies { implementation("com.google.devtools.ksp:symbol-processing-api:2.0.0-1.0.22")}
// --- Annotation definition (shared module) ---@Target(AnnotationTarget.CLASS)@Retention(AnnotationRetention.SOURCE)annotation class AutoBuilder
// --- Processor implementation ---import com.google.devtools.ksp.processing.*import com.google.devtools.ksp.symbol.*
class AutoBuilderProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> { // Find all classes annotated with @AutoBuilder val annotated = resolver .getSymbolsWithAnnotation("com.example.AutoBuilder") .filterIsInstance<KSClassDeclaration>()
annotated.forEach { classDecl -> generateBuilder(classDecl) }
return emptyList() // nothing to defer }
private fun generateBuilder(classDecl: KSClassDeclaration) { val className = classDecl.simpleName.asString() val packageName = classDecl.packageName.asString() val builderName = "${className}Builder"
// Get constructor parameters val params = classDecl.primaryConstructor?.parameters ?: return
// Generate builder class val file = codeGenerator.createNewFile( Dependencies(true, classDecl.containingFile!!), packageName, builderName )
file.bufferedWriter().use { writer -> writer.write("package $packageName\n\n") writer.write("class $builderName {\n")
// Generate mutable properties params.forEach { param -> val name = param.name?.asString() ?: return@forEach val type = param.type.resolve().declaration.qualifiedName?.asString() ?: "Any" writer.write(" private var $name: $type? = null\n") }
writer.write("\n")
// Generate setter methods params.forEach { param -> val name = param.name?.asString() ?: return@forEach val type = param.type.resolve().declaration.qualifiedName?.asString() ?: "Any" writer.write(" fun $name(value: $type): $builderName {\n") writer.write(" this.$name = value\n") writer.write(" return this\n") writer.write(" }\n\n") }
// Generate build method writer.write(" fun build(): $className {\n") writer.write(" return $className(\n") params.forEachIndexed { index, param -> val name = param.name?.asString() ?: return@forEachIndexed val comma = if (index < params.size - 1) "," else "" writer.write(" $name = $name ?: error(\"$name is required\")$comma\n") } writer.write(" )\n") writer.write(" }\n") writer.write("}\n\n")
// Generate extension function writer.write("fun $className.Companion.builder(): $builderName = $builderName()\n") }
logger.info("Generated builder for $className") }}
// --- Provider (registers the processor) ---class AutoBuilderProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return AutoBuilderProcessor( environment.codeGenerator, environment.logger ) }}Register the provider in a services file so KSP discovers it:
com.example.AutoBuilderProviderThen the app module applies the KSP plugin, depends on the processor with the ksp
configuration, and gets a generated builder for free:
// build.gradle.kts for the app moduleplugins { kotlin("jvm") id("com.google.devtools.ksp") version "2.0.0-1.0.22"}
dependencies { implementation(project(":annotations")) // shared annotation module ksp(project(":processor")) // the KSP processor module}
// Application code@AutoBuilderdata class User( val name: String, val email: String, val age: Int) { companion object}
// After compilation, you can use the generated builder:fun main() { val user = User.builder() .name("Alice") .email("alice@example.com") .age(30) .build()}Real-world KSP usage
Section titled “Real-world KSP usage”Many frameworks use KSP (or KAPT, its predecessor) instead of runtime reflection:
| Framework | What it generates |
|---|---|
| Room (Android) | SQL query implementations from DAO interfaces |
| Dagger/Hilt | Dependency injection wiring |
| kotlinx.serialization | JSON/Protobuf serializers from @Serializable |
| Moshi | JSON adapter from @JsonClass |
| Koin Annotations | DI module declarations |
| Compose | UI component metadata |
// kotlinx.serialization — KSP generates the serializer@Serializabledata class User( val name: String, val email: String, @SerialName("created_at") val createdAt: Instant)
// At compile time, KSP generates User.serializer() which knows// how to convert User to/from JSON, Protobuf, CBOR, etc.// No runtime reflection!val json = Json.encodeToString(User.serializer(), user)val user = Json.decodeFromString<User>(json) // reified generic!Kotlin Multiplatform: expect/actual
Section titled “Kotlin Multiplatform: expect/actual”What is Kotlin Multiplatform?
Section titled “What is Kotlin Multiplatform?”Kotlin Multiplatform (KMP) lets you share code between JVM, JS, Native, iOS, and WASM
targets. The expect/actual mechanism declares platform-specific APIs that each
target must implement: common code holds the expect declarations, and every target
provides its own actual.
flowchart TB C["Common code (expect declarations)"] C --> J["JVM (actual)"] C --> S["JS (actual)"] C --> N["Native (actual)"] C --> W["WASM (actual)"]
expect/actual basics
Section titled “expect/actual basics”An expect fun or expect class is like a header — it declares what exists, and
each target’s actual supplies the implementation:
// --- commonMain/kotlin/Platform.kt ---// `expect` declares WHAT exists (like an interface / header file)expect fun currentTimeMillis(): Long
expect class UUID { companion object { fun randomUUID(): UUID } fun toHexString(): String}
expect fun readEnvironmentVariable(name: String): String?
// --- jvmMain/kotlin/Platform.jvm.kt ---// `actual` provides the JVM implementationactual fun currentTimeMillis(): Long = System.currentTimeMillis()
actual class UUID(private val javaUuid: java.util.UUID) { actual companion object { actual fun randomUUID(): UUID = UUID(java.util.UUID.randomUUID()) } actual fun toHexString(): String = javaUuid.toString().replace("-", "")}
actual fun readEnvironmentVariable(name: String): String? = System.getenv(name)
// --- jsMain/kotlin/Platform.js.kt ---// `actual` provides the JavaScript implementationactual fun currentTimeMillis(): Long = js("Date.now()").unsafeCast<Double>().toLong()
actual class UUID(private val value: String) { actual companion object { actual fun randomUUID(): UUID = UUID(js("crypto.randomUUID()").unsafeCast<String>()) } actual fun toHexString(): String = value.replace("-", "")}
actual fun readEnvironmentVariable(name: String): String? = js("process.env[name]").unsafeCast<String?>()Cross-language comparison
Section titled “Cross-language comparison”// TS handles multi-platform with conditional imports or runtime checks// Not compile-time safe!
// Option 1: Different entry points// node.tsexport const readFile = (path: string) => fs.readFileSync(path, "utf-8");
// browser.tsexport const readFile = (path: string) => { throw new Error("File reading not supported in browser");};
// Option 2: Runtime detectionconst isNode = typeof process !== "undefined" && process.versions?.node;const readFile = isNode ? require("fs").readFileSync : () => { throw new Error("Not supported"); };// Go uses build tags for platform-specific code
//go:build linux
package platform
func GetConfigDir() string { return os.Getenv("XDG_CONFIG_HOME")}
// platform_darwin.go//go:build darwin
package platform
func GetConfigDir() string { home, _ := os.UserHomeDir() return filepath.Join(home, "Library", "Application Support")}// Compile-time checked: if you forget an `actual`, the build fails.// The IDE shows which actuals are missing for which targets.expect fun currentTimeMillis(): Longactual fun currentTimeMillis(): Long = System.currentTimeMillis()Key differences:
- TypeScript has no compile-time multiplatform — you use bundler configs or runtime checks.
- Go build tags are string-based, not type-checked across platforms.
- Kotlin’s
expect/actualis fully type-checked at compile time — missing implementations are compile errors.
Multiplatform project structure
Section titled “Multiplatform project structure”A KMP build declares its targets and per-target source sets:
plugins { kotlin("multiplatform") version "2.0.0"}
kotlin { jvm() js(IR) { browser() nodejs() } // Native targets linuxX64() macosX64() macosArm64()
sourceSets { val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0") } } val commonTest by getting { dependencies { implementation(kotlin("test")) } } val jvmMain by getting { dependencies { implementation("io.ktor:ktor-client-cio:3.0.0") } } val jsMain by getting { dependencies { implementation("io.ktor:ktor-client-js:3.0.0") } } }}The directory layout mirrors those source sets — shared code in commonMain, with
each platform’s actual implementations in its own folder:
Directorysrc/
DirectorycommonMain/kotlin/ shared code (expect declarations, pure logic)
- Platform.kt
Directorymodel/
- User.kt @Serializable data classes
Directoryservice/
- UserService.kt business logic (pure Kotlin)
DirectorycommonTest/kotlin/ shared tests
- …
DirectoryjvmMain/kotlin/ JVM-specific (actual implementations)
- Platform.jvm.kt
DirectoryjsMain/kotlin/ JS-specific
- Platform.js.kt
DirectorynativeMain/kotlin/ Native-specific
- Platform.native.kt
Sharing business logic
Section titled “Sharing business logic”The real power of KMP is sharing business logic — validation, models, an API client
— across platforms, with only the truly platform-specific bits behind expect:
// --- commonMain ---// Pure business logic — no platform dependencies@Serializabledata class User(val id: String, val name: String, val email: String)
@Serializabledata class CreateUserRequest(val name: String, val email: String)
class UserValidator { fun validate(request: CreateUserRequest): List<String> { val errors = mutableListOf<String>() if (request.name.isBlank()) errors.add("Name is required") if (request.name.length > 100) errors.add("Name too long") if (!request.email.contains("@")) errors.add("Invalid email") return errors }}
// This can be used on JVM backend, JS frontend, and iOS/Android clients// Same validation logic everywhere — no duplication!
// Platform-specific HTTP clientexpect class HttpClient() { suspend fun get(url: String): String suspend fun post(url: String, body: String): String}
// Shared service using the platform-specific clientclass UserApiClient(private val baseUrl: String) { private val client = HttpClient() private val json = Json { ignoreUnknownKeys = true }
suspend fun getUser(id: String): User { val response = client.get("$baseUrl/users/$id") return json.decodeFromString(response) }
suspend fun createUser(request: CreateUserRequest): User { val body = json.encodeToString(request) val response = client.post("$baseUrl/users", body) return json.decodeFromString(response) }}Summary
Section titled “Summary”| Feature | TypeScript Equivalent | Go Equivalent | Kotlin |
|---|---|---|---|
| DSL builders | Method chaining / tagged templates | Functional options | Lambda with receiver + @DslMarker |
| Class delegation | Manual forwarding | Struct embedding | class Foo : Bar by impl |
| Property delegation | getter/setter, decorators | No equivalent | var x by lazy { } |
| Reified generics | Not possible (types erased) | Type assertions | inline fun <reified T> |
| Context parameters | Constructor injection | context.Context | context(Foo) |
| Code generation | Decorators (runtime) | go generate | KSP (compile-time) |
| Multiplatform | Runtime checks, bundler | Build tags | expect/actual |
What to remember:
- Kotlin DSLs are not magic — they combine extension functions, lambdas with
receivers, and
@DslMarker. - Delegation (
by) replaces inheritance and manual forwarding with zero-cost composition. inline+reifiedsolves the JVM type erasure problem at compile time.- Context receivers/parameters are Kotlin’s answer to “ambient” dependencies.
- KSP generates code at compile time, avoiding runtime reflection overhead.
- Kotlin Multiplatform lets you share real business logic across JVM, JS, and Native.
Practice
Section titled “Practice”Put these patterns to work — both exercises build the kind of small, expressive library that shows off Kotlin’s metaprogramming features.