Kotlin Advanced Type System · Complete Reference · Vol. 05 Extended

Generics,
Lambdas &
Functions

A complete deep-dive into Kotlin's type system and function abstractions — every variant, every trade-off, every pros and cons. From how generics are erased at runtime to the difference between a SAM and an anonymous function, to building type-safe DSLs. Nothing omitted.

T
Type Erasure
Generic types erased to Any? at JVM bytecode level. reified inline functions are the escape hatch.
in/out
Variance Keywords
Covariant (out) and contravariant (in) control subtype relationships between generic types.
0
Lambda Allocations (inline)
Inline functions paste lambda body at call site — no Function object, no heap allocation.
01
Type System Foundation

Generics — All Cases

Why Generics Exist

Without generics, every collection API returns Any — you lose type safety and must cast everywhere, with runtime ClassCastException risk. Generics let you write code that's parameterized over types: the same algorithm works for any type, but the compiler enforces correctness at each specific usage.

The JVM implements generics via type erasure: the type parameter exists only in the source and bytecode metadata, not in the actual runtime JVM instructions. At runtime, a List<String> is just a List. This was a Java backward-compatibility decision and Kotlin inherits it.

"Generics are a compile-time contract. The compiler uses them to enforce type safety, then erases them before handing the bytecode to the JVM."

Without vs With Generics
// ✗ WITHOUT generics — unsafe, verbose, error-prone
fun getFirst(list: List<Any>): Any = list[0]
val s = getFirst(listOf("a")) as String // runtime cast, can throw

// ✓ WITH generics — type-safe, no cast needed
fun <T> getFirst(list: List<T>): T = list[0]
val s: String = getFirst(listOf("a")) // compiler checks T = String

// TYPE ERASURE: at runtime both are the same bytecode
// List<String> == List<Int> == List (raw type) to the JVM
val ls: List<String> = listOf("a")
val li: List<Int>    = listOf(1)
println(ls.javaClass == li.javaClass) // true! Both are ArrayList

// CONSEQUENCE: can't check generic types at runtime
if (ls is List<String>) { }  // ✗ Compile error: erased type
if (ls is List<*>) { }      // ✓ Star projection allowed

Generic Functions vs Classes vs Interfaces

Generic Function

Per-call inference

Type is inferred at each call site independently. Type parameter is scoped to a single invocation. Return type and parameter types can both reference T.

fn
fun <T> wrap(v: T): List<T> =
    listOf(v)

val ints = wrap(42)     // T=Int
val strs = wrap("hi")  // T=String
✓ Pros
+Lightweight — no class overhead
+Type inferred automatically
✗ Cons
No state — pure computation only
Can't persist type across calls
Generic Class

Per-instance binding

Type is fixed when you create the instance: Box<String>. All methods on that instance share the same T. Enables stateful, type-safe containers.

class
class Box<T>(var value: T) {
  fun map(f: (T) -> T): Box<T> =
    Box(f(value))
}
val b = Box(10)      // T=Int
val b2 = b.map{it*2}
✓ Pros
+Stateful type-safe containers
+Methods share the bound type
✗ Cons
Heavier — class allocation
Type fixed at construction time
Generic Interface

Polymorphic contract

Implementing classes can choose or fix the type. Enables type-safe polymorphism: different implementations with different T while sharing a common API shape.

interface
interface Repo<T> {
  fun findById(id: Long): T?
  fun save(item: T)
}
class UserRepo: Repo<User> {
  override fun findById(...) = ...
}
✓ Pros
+Polymorphism with type safety
+Mockable, testable contracts
✗ Cons
More boilerplate per implementation
Can't have reified methods

Type Constraints — All Forms

Kotlin — All constraint forms
// 1. Single upper bound — T must subtype the bound
fun <T : Comparable<T>> max(a: T, b: T): T =
    if (a > b) a else b
// Without it, T is Any? — you can call nothing useful on T

// 2. Multiple bounds — 'where' clause required
fun <T> process(item: T): String
    where T : Serializable,
          T : Comparable<T> { return item.toString() }

// 3. Non-nullable bound — forbids nullable type argument
fun <T : Any> nonNull(v: T): T = v
// nonNull(null)      ✗ T must be non-nullable
// nonNull<String?>() ✗ Same

// 4. No bound — T is effectively Any? (nullable)
fun <T> identity(v: T): T = v
// Can call Any? methods: toString(), hashCode(), ==, etc.

// 5. Recursive bound — T compared to itself
class SortedList<T : Comparable<T>> {
    private val items = mutableListOf<T>()
    fun add(item: T) { items.add(item); items.sort() }
}

// 6. Multiple type parameters with cross-constraints
fun <K : Comparable<K>, V> sortedMapOf(
    vararg pairs: Pair<K, V>
): Map<K, V> = sortedMapOf(*pairs)

Star Projection vs Wildcards

When you don't know or don't care about the type argument, Kotlin uses star projection List<*>. This is roughly equivalent to Java's unbounded wildcard List<?>, but Kotlin's type system is more precise about what you can do with it.

Star projection rules
// List<*> = List<out Any?>: can READ Any?, cannot WRITE
fun printAll(list: List<*>) {
    list.forEach { println(it) }   // ✓ read as Any?
    list.add("x")                  // ✗ can't add — type unknown
}

// MutableList<*> = MutableList<out Any?> for reads
// Cannot call methods that take T as parameter
val ml: MutableList<*> = mutableListOf("a", "b")
val item = ml[0]   // type is Any? — need cast to use
ml.add("c")        // ✗ Error: Nothing is passed as T

// Map<*, String>: key type unknown, value is String
// Map<String, *>: key is String, value type unknown
fun valuesOf(map: Map<*, String>): List<String> =
    map.values.toList()

// Nothing as lower bound: for out-projected write-only
// Foo<in *> ≡ Foo<in Nothing>: can't pass any value
✓ Star Projection Use
+Printing/logging collections without caring about element type
+Reflection on generic types when type isn't known
+Type checks: x is List<*> compiles fine
✗ Limitations
All reads return Any? — requires cast to be useful
Cannot write to projected-out containers
Loses compile-time type information
✓ Generics — Strengths
+Type safety: catch ClassCastException at compile time, not at user's device
+Code reuse: one algorithm works for any type without copy-pasting
+IDE support: autocompletion knows the type inside generic code
+Zero runtime cost: erased, no extra objects or overhead at JVM level
+Interop: matches Java generics model, Java code can use Kotlin generics naturally
✗ Generics — Limitations
Type erasure: cannot do is T or T::class at runtime without reified
Cannot create instances: T() doesn't compile — type unknown at runtime
Overloading collision: fun f(l: List<String>) and fun f(l: List<Int>) clash after erasure
Array generics: Array<T> is reified but List<T> is not — different rules
Variance complexity: in/out modifiers add mental overhead
02
Type Relationships

Variance — in, out, Invariant

The Core Problem

If Dog is a subtype of Animal, is List<Dog> a subtype of List<Animal>? Intuitively yes — but it depends entirely on what you can do with the list. If you can only read from it, Dog-lists work as Animal-lists. If you can write to it, allowing Dog-lists as Animal-lists would let someone insert a Cat — breaking type safety.

Variance is how Kotlin (and the JVM) controls this. The default is invariant — no subtype relationship. You opt-in to covariance (out) or contravariance (in) when it's safe.

TYPE HIERARCHY EXAMPLE
Animal
├──
Dog
subtype of Animal
└──
Cat
subtype of Animal
List<Dog>
out T
→ IS a List<Animal>
MutableList<Dog>
invariant
✗ NOT a MutableList<Animal>
Consumer<Animal>
in T
→ IS a Consumer<Dog>
PRODUCER
Covariant
out T

If Dog : Animal then Source<Dog> : Source<Animal>. Subtype relationship is preserved.

Can return T — T appears in out/return positions
Cannot accept T — T cannot be a parameter
Stdlib examples: List<out T>, Flow<T>, Sequence<T>
CONSUMER
Contravariant
in T

If Dog : Animal then Sink<Animal> : Sink<Dog>. Subtype relationship is reversed.

Can accept T — T appears in in/parameter positions
Cannot return T — T cannot appear as return type
Stdlib: Comparator<in T>, Channel.send, Continuation
DEFAULT
Invariant
T (no modifier)

MutableList<Dog> is NOT related to MutableList<Animal>. No subtype relationship either way.

Can both read and write T
No subtype relationship — must match exactly
Stdlib: MutableList<T>, MutableMap<K,V>, Channel<T>

Declaration-Site Variance

You annotate the type parameter on the class/interface definition itself. Applies to all usages of that type everywhere. Best when the type is inherently a producer or consumer — like List (always read-only, always out T).

Declaration-site — out and in
// OUT: Producer — T only in return positions
class Producer<out T>(private val v: T) {
    fun get(): T = v        // ✓ T in return
    // fun set(v: T) {}       ✗ T in param — error
}
val dp: Producer<Dog> = Producer(Dog())
val ap: Producer<Animal> = dp  // ✓ covariant

// IN: Consumer — T only in parameter positions
interface Consumer<in T> {
    fun consume(item: T)    // ✓ T in param
    // fun produce(): T      ✗ T in return — error
}
val ac: Consumer<Animal> = ...
val dc: Consumer<Dog> = ac   // ✓ contravariant

// Real-world: Comparable<in T>
// String : Comparable<String>
// Comparator<in Animal> can sort Dogs (contravariance)

Use-Site Variance (Type Projection)

When you can't change the class definition (e.g. MutableList must be invariant), you apply variance at the usage site. You project the type to be read-only (out) or write-only (in) at that specific call.

Use-site variance — type projections
// MutableList is invariant — can't pass Dog list as Animal list
fun bad(animals: MutableList<Animal>) { ... }
bad(mutableListOf(Dog(), Dog()))  // ✗ Type mismatch

// out projection: read-only view of MutableList
fun printAll(list: MutableList<out Animal>) {
    list.forEach { println(it.name) }   // ✓ read as Animal
    list.add(Cat())                      // ✗ can't write
}
printAll(mutableListOf(Dog(), Dog()))  // ✓ now compiles

// in projection: write-only view
fun fillDogs(dest: MutableList<in Dog>) {
    dest.add(Dog())                      // ✓ write Dog
    // val d: Dog = dest[0]              ✗ reads back as Any?
}
fillDogs(mutableListOf<Animal>())     // ✓ Animal accepts Dog

// Kotlin copy() idiom (like Java wildcard copy)
fun <T> copy(src: MutableList<out T>,
              dst: MutableList<in T>) {
    src.forEach { dst.add(it) }
}

All Variance Forms Compared

FormSyntaxSubtype ruleCan read T?Can write T?Stdlib exampleWhen to use
InvariantTNo relationship✓ Any✓ AnyMutableListDefault — mutable containers
Covariant (decl)out TPreserved ↓✓ as TList, FlowRead-only producers
Contravariant (decl)in TReversed ↑✓ as TComparatorWrite-only consumers
Out projection (use)MutableList<out T>Preserved for that call✓ as Tcopy() src paramCan't change class, need covariance
In projection (use)MutableList<in T>Reversed for that call✓ as Any?✓ as Tcopy() dst paramCan't change class, need contravariance
Star projectionList<*>Unknown T✓ as Any?printAll()Type not known, don't care
✓ Declaration-Site Pros
+Write variance once, applies everywhere — less repetition
+API users don't need to think about variance
+Compiler enforces it — can't accidentally violate
✗ Declaration-Site Cons
Type parameter must be used consistently in all positions
Cannot have both read and write methods on same T
Forces API design decision upfront — hard to change
✓ Use-Site Pros
+Works on any existing type, even Java classes
+Flexible — different usages can have different variance
✗ Use-Site Cons
Verbose — must repeat at every call site
Easy to forget — no compiler enforcement of API intent
03
Compiler Optimization

Inline Functions — All Modifiers

The Lambda Allocation Problem

Every non-inlined lambda passed to a higher-order function creates a FunctionN object on the heap. Each call to that HOF: one allocation. In a forEach on a 10,000-element list, that's potentially thousands of calls going through a virtual dispatch on a Function object. In animation loops, RecyclerView binds, or hot coroutine paths, this matters.

inline solves this by instructing the compiler to paste the function body — and all inlined lambda bodies — directly at every call site. The call disappears; no object, no dispatch.

⚠ When NOT to inline

Large functions called many times inflate bytecode. Recursive functions can't be inlined. Public inline functions become part of the binary API and are harder to change. Use inline for: small HOFs taking lambdas, reified generics, non-local return scenarios.

Before vs After inlining — bytecode view
// Source code
inline fun measure(block: () -> Unit) {
    val t = System.nanoTime()
    block()
    println(System.nanoTime() - t)
}
measure { doWork() }

// What the compiler generates at call site:
// (no measure() call, no Function object)
val t = System.nanoTime()
doWork()                       // ← block body pasted
println(System.nanoTime() - t)

// Without inline — generated bytecode:
// val fn = new Function0() { void invoke() { doWork(); } }
// measure(fn)
// → INVOKEVIRTUAL Function0.invoke  (virtual dispatch)
// → heap allocation for fn object
inline

Base modifier

Pastes both function body and all lambda parameters at every call site. Enables non-local returns inside the inlined lambdas. Eliminates all FunctionN allocations for inlined lambdas.

+Zero lambda allocation
+Non-local returns allowed
+Enables reified generics
Binary size growth
Cannot be recursive
Can't store lambda in field
noinline

Per-lambda opt-out

Applied to a specific lambda param within an inline function. That lambda is NOT inlined — stays as a Function object. Use when you need to store the lambda, pass it to another function, or delay its invocation.

+Can store in field/property
+Can pass to non-inline APIs
FunctionN allocation returns
Non-local return blocked
crossinline

Inline but local-return

Lambda IS inlined (no allocation) but non-local returns are forbidden. Required when the lambda is called inside a different lambda context (Runnable, coroutine, callback) within the inline function body.

+Still zero allocation
+Safe in async contexts
No non-local returns
Subtle restriction to explain
Kotlin — All three modifiers with non-local return examples
// ── inline: basic, enables non-local return ────────────────────────
inline fun forEach2<T>(list: List<T>, action: (T) -> Unit) {
    for (item in list) action(item)
}
fun findEven(list: List<Int>): Int? {
    list.forEach2 {
        if (it % 2 == 0) return it  // ← NON-LOCAL: returns from findEven
    }
    return null
}

// ── noinline: must store lambda in property ────────────────────────
class EventBus {
    private var listener: ((String) -> Unit)? = null

    inline fun register(
        noinline onEvent: (String) -> Unit,   // stored — must be noinline
        onRegister: () -> Unit                  // inlined
    ) {
        listener = onEvent    // ✓ can store noinline lambda
        onRegister()          // inlined here
    }
}

// ── crossinline: inlined but no non-local return ───────────────────
inline fun postOnMain(crossinline block: () -> Unit) {
    Handler(Looper.getMainLooper()).post {
        block()   // invoked inside Runnable — crossinline required
        // if block could non-local-return, it would try to return
        // from an already-returned enclosing function — impossible
    }
}
postOnMain {
    updateUI()
    // return           ✗ Compile error: non-local return not allowed here
    // return@postOnMain ✓ Labeled return from lambda OK
}

// ── Non-local return in non-inline: compile error ─────────────────
fun nonInline(block: () -> Unit) { block() }
fun demo() {
    nonInline {
        return   // ✗ 'return' not allowed here — not inline
    }
}
ModifierLambda inlined?FunctionN alloc?Non-local return?Can store lambda?Use when
inline (no modifier)✓ Yes✓ Eliminated✓ Allowed✗ NoAll HOF lambdas you don't need to store
noinline✗ No✗ Still allocated✗ Blocked✓ YesLambda must be stored or passed to non-inline API
crossinline✓ Yes✓ Eliminated✗ Blocked✗ NoLambda invoked inside another lambda/callback in body
04
Runtime Type Access

Reified Type Parameters

The Type Erasure Escape Hatch

reified only works on inline functions. Because the compiler pastes the function body at each call site, it knows the exact type argument used at that call. It substitutes the real type into the pasted code — so T::class, value is T, and typeOf<T>() all work inside reified inline functions.

The alternative — passing KClass<T> explicitly — achieves the same thing but is more verbose at the call site and doesn't enable the is T check syntax directly.

"reified is not magic. It's the compiler doing the T→ActualType substitution you'd otherwise do manually by passing KClass<T> as a parameter."

Reified — what the compiler does
// Source — what you write:
inline fun <reified T> parseJson(json: String): T =
    gson.fromJson(json, T::class.java)

val user = parseJson<User>(jsonString)

// What the compiler generates at call site:
// (T is substituted with User — the actual type)
val user = gson.fromJson(jsonString, User::class.java)
// No parseJson() call. No KClass parameter. Just the body, pasted.

// The bytecode is equivalent to you having written it by hand.
// This is why it's "zero runtime overhead" — it IS your code.

// What happens with EACH different call site:
val user    = parseJson<User>(s)    // pasted: ...User::class.java
val product = parseJson<Product>(s) // pasted: ...Product::class.java
val order   = parseJson<Order>(s)   // pasted: ...Order::class.java

reified vs Passing KClass<T> — Full Comparison

Without reified — KClass parameter pattern
// Must pass KClass explicitly — every call site verbose
fun <T : Any> inject(clazz: KClass<T>): T =
    container.get(clazz)

// Call site: verbose, error-prone
val repo = inject(UserRepository::class)
val svc  = inject(EmailService::class)

// Can't do 'is T' check — T is not the KClass, still erased
fun <T : Any> filterType(
    list: List<*>, clazz: KClass<T>
): List<T> {
    @Suppress("UNCHECKED_CAST")
    return list.filter { clazz.isInstance(it) } as List<T>
}
// Call site:
filterType(items, String::class)
With reified — clean call sites
// Reified: KClass is implicit, T IS the type at runtime
inline fun <reified T> inject(): T =
    container.get(T::class)

// Call site: clean, no class reference needed
val repo = inject<UserRepository>()
val svc  = inject<EmailService>()

// 'is T' check now works — T substituted at call site
inline fun <reified T> List<*>.filterType(): List<T> =
    filterIsInstance<T>()  // stdlib uses reified internally

// Call site:
val strings = items.filterType<String>()

// Android ViewModel without boilerplate
inline fun <reified VM : ViewModel> Fragment.viewModel(): VM =
    ViewModelProvider(this)[VM::class.java]
// Usage: val vm: MyViewModel by viewModel()
CapabilityRegular generic TKClass<T> paramreified T
T::class✗ Erased✓ clazz✓ Direct
value is T✗ Erased✓ clazz.isInstance(v)✓ Direct
T::class.java✓ clazz.java✓ Direct
typeOf<T>()✗ Partial✓ Full generic info
Call-site verbosityCleanMust pass ::classClean
No allocation overhead✓ (inline)
Works in non-inline fns✗ inline only
Full generic type token (List<T>)✗ Class only✓ via typeOf
✓ Reified Strengths
+Ergonomic: call sites are clean — no explicit KClass argument
+Full type syntax: is T, T::class, as T all work naturally
+typeOf<T>(): can capture full generic type including type arguments (e.g. List<String>)
+No reflection: zero runtime overhead — compiler substitutes at build time
✗ Reified Limitations
Inline only: cannot use reified on regular functions or class-level type params
Binary size: each call site gets a full copy of the function body
No runtime reflection power: you get the type but can't instantiate it without a factory
Not callable from Java: inline functions are JVM-invisible to Java callers
05
First-Class Functions

Lambdas — All Forms, Internals, Performance

Full
{ a: Int, b: Int -> a+b }
Explicit param types + arrow + body. Always compiles. Use when types can't be inferred.
Inferred
{ a, b -> a + b }
Types inferred from context. Cleaner when the function type is clear from the call site.
Trailing
func(x) { body }
Last lambda param moved outside parens. Foundation of Kotlin's DSL-like API feel.
it — implicit
{ it * 2 }
Single param auto-named it. Use for simple transforms. Avoid for nested lambdas (ambiguous which it).
Reference
::uppercase
Obj::method
Member/function reference. Interchangeable with matching lambda. Can be top-level, bound, or constructor refs.

Lambda as a Type: FunctionN

Every lambda type in Kotlin is an interface: (A) -> B compiles to Function1<A, B>. The JVM generates an anonymous class implementing this interface. The number suffix is the arity: Function0 through Function22. Beyond 22 params, Kotlin uses FunctionN with a vararg.

This means: every non-inlined lambda = one heap-allocated object. Capturing lambdas create a new object per usage context. Non-capturing lambdas are compiled to singletons (one object total). This distinction matters in hot paths.

Lambda types and FunctionN compilation
// Function types and their FunctionN equivalents
val f0: () -> Unit              // Function0<Unit>
val f1: (Int) -> String        // Function1<Int, String>
val f2: (Int, String) -> Bool  // Function2<Int, String, Bool>

// Kotlin suspending lambda type
val sf: suspend () -> Unit      // SuspendFunction0<Unit>

// Lambda with receiver type
val ext: String.() -> Int       // Function1<String, Int> (receiver = first param)

// ── Allocation analysis ────────────────────────────────────
// NON-CAPTURING: compiled to a SINGLETON object
val pure = { x: Int -> x * 2 }      // one object ever
list.map { it * 2 }                  // reuses same singleton

// CAPTURING: NEW object per call context
fun multiplier(n: Int) = { x: Int -> x * n }
val times3 = multiplier(3)  // allocates object with n=3
val times5 = multiplier(5)  // allocates another with n=5

// MUTABLE CAPTURE: captured variable wrapped in a Ref object
var count = 0
val inc = { count++ }          // count wrapped in IntRef()
// Bytecode: val ref = new IntRef(); ref.element = 0
// { ref.element++ }

Closures — Capturing Rules

A closure is a lambda that captures variables from its enclosing scope. In Kotlin, unlike Java, you can capture and mutate local variables (Java only allows effectively-final). This is implemented by wrapping the variable in a Ref object on the heap.

Closure capture and mutation
// IMMUTABLE CAPTURE — val captured directly in lambda
val prefix = "Hello"
val greet: (String) -> String = { "$prefix, $it" }
// 'prefix' is a String (immutable), copied into lambda object field

// MUTABLE CAPTURE — var wrapped in Ref by compiler
var total = 0
val items = listOf(1, 2, 3)
items.forEach { total += it }  // captures and mutates total
println(total)  // 6
// Compiled as: val totalRef = IntRef(); totalRef.element = 0
// lambda: { totalRef.element += it }

// COMMON PITFALL: capture in loop
val fns = mutableListOf<() -> Int>()
for (i in 0..2) {
    fns.add { i }  // each lambda captures the IntRef of i
}
// All fns reference the SAME IntRef. After loop, i=3.
fns.map { it() }  // [0, 1, 2] — actually OK, range is immutable
// But with var i = 0; while(i < 3): fns would all return 3!

// SOLUTION for the pitfall: capture a snapshot
for (i in 0..2) {
    val snapshot = i  // new immutable capture each iteration
    fns.add { snapshot }
}
✓ Lambda Strengths
+Concise: trailing lambda syntax makes HOF APIs read like language features
+First-class: storable in variables, passable, returnable — full FP support
+Mutable capture: unlike Java, can close over and mutate local vars
+Implicit it: single-param lambdas are extremely terse
+Composable: chains like .map{}.filter{}.fold{} read naturally
✗ Lambda Limitations
Allocation: every non-inlined lambda creates a FunctionN heap object
Non-local return gotcha: unlabeled return exits the enclosing function, not just the lambda
Ambiguous it: nested lambdas both using it require explicit naming
Mutable capture cost: wraps variable in Ref object — extra allocation and indirection
No explicit return type: cannot annotate return type on a lambda literal
06
Single Abstract Method

SAM Conversions & fun interface

What is SAM?

SAM (Single Abstract Method) conversion allows passing a lambda where a functional interface is expected — any interface with exactly one abstract method. The compiler automatically wraps your lambda in an anonymous class implementing that interface.

Java has had this since Java 8 — it's why you can write button.setOnClickListener { view -> ... } instead of button.setOnClickListener(new View.OnClickListener() { ... }). Kotlin extends this with fun interface for defining your own SAM types.

SAM — Java interop and fun interface
// ── Java SAM: Runnable ────────────────────────────────────
// Java: executor.execute(new Runnable() { public void run() { } })
executor.execute { println("hello") }   // SAM conversion
executor.execute(Runnable { println("hello") }) // explicit

// Comparator<in T> — Java SAM with generic
val comparator: Comparator<String> = Comparator { a, b ->
    a.length - b.length
}
// Or concisely:
listOf("banana", "fig", "apple").sortedWith { a, b ->
    a.length - b.length
}

// ── Kotlin fun interface ──────────────────────────────────
fun interface Transformer<T, R> {
    fun transform(input: T): R           // single abstract method
    fun andThen(other: Transformer<R, R>)  // non-abstract: allowed
        : Transformer<T, R> = Transformer { other.transform(transform(it)) }
}
val double: Transformer<Int, Int> = { it * 2 }  // SAM conversion
println(double.transform(5))  // 10

SAM vs fun interface vs Function Type — When to Use Each

Java SAM interface

Java interop

Works automatically with any Java functional interface (Runnable, Callable, Comparator, etc). The compiler generates the anonymous class at the call site.

+No Kotlin code needed to use Java APIs
+Full Java ecosystem compatibility
Only for Java interfaces, not Kotlin interfaces
No control over the contract
fun interface

Named contract

Kotlin's own SAM interface. Has a name, can have default method implementations, can extend other interfaces, can have properties. Lambda-convertible at call sites.

+Named type — useful in APIs and Java interop
+Can add default methods and properties
+Still lambda-convertible
More boilerplate than function type
Exactly one abstract method restriction
Function type (A) → B

Anonymous callable

The lightest option. No named type, just a callable signature. Cannot have default methods. Directly assignable, no wrapper class needed. The standard Kotlin way to pass behavior.

+Least boilerplate — just write the type
+Composable with higher-order functions
+Inline-able for zero allocation
No named type — harder to distinguish semantically identical callbacks
No Java SAM conversion from Kotlin lambda
FeatureJava SAMKotlin fun interfaceFunction type (A)→B
Lambda conversion✓ Automatic✓ Automatic✓ Always
Named type✓ Has name✓ Has name✗ Anonymous
Default methods✓ Java default✓ fun impls✗ None
Extension functions✓ On FunctionN
Kotlin interface✗ Java only✓ FunctionN
Java interop (call from Java)✓ Native✓ As FunctionN
inline-able
Type alias possible✗ not needed✓ typealias
07
Function Literals

Anonymous Functions — Return Semantics

The One Critical Difference

An anonymous function is a function literal written with the fun keyword but without a name. It looks like a regular function you can assign or pass. The only thing that truly sets it apart from a lambda is return semantics.

In a lambda, an unlabeled return is a non-local return — it exits the enclosing named function, not just the lambda. This only works inside inline functions. In an anonymous function, return always exits the anonymous function itself — local, predictable, no surprises.

"Choose anonymous function when you have multiple return points and labeled returns feel unnatural. Choose lambda everywhere else."

Anonymous function syntax
// Basic anonymous function
val double = fun(x: Int): Int { return x * 2 }

// Expression form (single expression)
val triple = fun(x: Int) = x * 3

// With explicit return type declaration — impossible with lambda
val safeDivide = fun(a: Int, b: Int): Double? {
    if (b == 0) return null     // exits anonymous function
    if (a == b) return 1.0       // exits anonymous function
    return a.toDouble() / b     // exits anonymous function
}

// Passing as argument
listOf(1, 2, 3).filter(fun(it): Boolean {
    return it > 1   // exits filter predicate, not enclosing function
})

// Stored in variable — identical type signature to lambda
val fn: (Int) -> Int = fun(x: Int): Int { return x + 1 }
val la: (Int) -> Int = { x -> x + 1 }  // exactly same type

Return Semantics — The Full Matrix

All return forms in Kotlin
fun demo(items: List<Int>): String {

    // ── CASE 1: Lambda inside INLINE function ─────────────────────────────
    // forEach is inline, so return is non-local — exits 'demo'
    items.forEach {
        if (it == 0) return "found zero"  // ← exits 'demo'!
    }

    // ── CASE 2: Lambda with label — local return ──────────────────────────
    items.forEach forEachLabel@{
        if (it == 0) return@forEachLabel  // ← exits current forEach iteration only
        println(it)
    }

    // ── CASE 3: Implicit label — cleaner syntax ───────────────────────────
    items.forEach {
        if (it == 0) return@forEach  // ← implicit label = function name
        println(it)
    }

    // ── CASE 4: Anonymous function — always local return ──────────────────
    items.forEach(fun(item: Int) {
        if (item == 0) return  // ← exits THIS anonymous function only, not 'demo'
        println(item)            // completely equivalent to Case 3 semantically
    })

    // ── CASE 5: Lambda inside NON-INLINE — non-local return is error ──────
    val nonInlineFn: (() -> Unit) -> Unit = { block -> block() }
    nonInlineFn {
        return "error"  // ✗ Compile error: 'return' not allowed here
        // Must use: return@nonInlineFn or labeled return
    }

    // ── CASE 6: Coroutine launch — non-local return forbidden ─────────────
    val job = scope.launch {   // launch is inline but block is crossinline
        return@demo  // ✗ Can't return from coroutine to outer function
        // return@launch  ✓ exits the coroutine block
    }

    return "done"
}
ScenarioReturn syntaxExitsRequires inline?Notes
Lambda in inline fnreturn valueEnclosing named function✓ YesNon-local return — powerful but surprising
Lambda — labeledreturn@label valueCurrent lambda only✗ NoWorks in both inline and non-inline
Lambda — implicit labelreturn@fnNameCurrent lambda only✗ NoLabel = the HOF function name
Anonymous functionreturn valueAnonymous function only✗ NoAlways local — predictable
Lambda in non-inlinereturn✗ Compile errorN/ANon-local return not allowed
✓ Anonymous Function — When to prefer
+Multiple return points: cleaner than labeled returns when you need to bail early from complex logic
+Explicit return type: when you want to declare (): Double? for clarity — impossible with lambda
+Non-inline HOF: inside non-inline functions, anonymous function return semantics are same as labeled lambda but less syntax noise
+Clarity in complex logic: long, multi-step functions passed as arguments are clearer with fun syntax
✗ Anonymous Function — Limitations
No trailing syntax: must be inside parens, can't use the cleaner trailing lambda form
No implicit it: must name all parameters explicitly — slightly more verbose
Uncommon in idiomatic Kotlin: most codebases use lambdas with labels; anonymous functions may confuse readers
Same allocation cost: not inline — still creates a FunctionN object
08
Builder Pattern Evolved

Type-Safe DSLs — Receivers, Builders, Markers

Lambda with Receiver — The Foundation

A lambda with receiver is a function type where one extra "receiver" object is implicitly available as this inside the lambda. The type is written T.() -> R. Calling such a lambda on an object T lets you access all its members without qualification.

This is the entire mechanism behind Kotlin's DSL capability: you create a builder class, define methods on it, then write a builder function that takes a BuilderClass.() -> Unit lambda and calls it on a new instance. The lambda body then looks like a mini-language.

Lambda with receiver — the core mechanism
// Extension function: calls obj.doSomething()
fun StringBuilder.addHello() = append("hello")

// Lambda with receiver: same access, but as a value
val addHello: StringBuilder.() -> Unit = { append("hello") }
// These are semantically equivalent. The lambda is a function
// value with StringBuilder as its implicit 'this'.

// Calling a receiver lambda:
val sb = StringBuilder()
sb.addHello()       // extension function call
sb.addHello()       // receiver lambda called on sb
// OR: addHello(sb) — receiver is first parameter under the hood

// The stdlib 'apply' is just this pattern:
inline fun <T> T.apply(block: T.() -> Unit): T {
    block()    // 'this' inside block = the T receiver
    return this
}
// Usage: creates new AlertDialog with configuration
AlertDialog.Builder(ctx).apply {
    setTitle("Warning")   // this = Builder, no 'this.' needed
    setMessage("Sure?")
}.create()

Scope Functions — The Stdlib DSL Primitives

All five scope functions — internals and usage
// ── apply: T.(T.() → Unit) → T ─────────────────────────────────────
// Context: this=T, returns T. Use: object configuration
inline fun <T> T.apply(block: T.() -> Unit): T { block(); return this }
val user = User().apply {
    name = "Alice"   // this.name — receiver is User instance
    age  = 30
}  // returns the User itself

// ── with: (T, T.() → R) → R ─────────────────────────────────────────
// Context: this=T, returns R. Use: multiple operations, return result
inline fun <T, R> with(receiver: T, block: T.() -> R): R = receiver.block()
val json = with(StringBuilder()) {
    append("""{"name":"""")
    append(user.name)
    append(""""}""")
    toString()  // ← return value
}

// ── run: T.(T.() → R) → R ───────────────────────────────────────────
// Context: this=T, returns R. Like with but as extension function
inline fun <T, R> T.run(block: T.() -> R): R = block()
val validated = user.run {
    require(name.isNotEmpty()) { "Name required" }  // this = user
    copy(name = name.trim())  // ← return transformed copy
}

// ── let: T.((T) → R) → R ─────────────────────────────────────────
// Context: it=T (parameter), returns R. Use: null-safe chain, transform
inline fun <T, R> T.let(block: (T) -> R): R = block(this)
user.email?.let { email ->   // only runs if email != null
    sendConfirmation(email)   // email is non-null here
}

// ── also: T.((T) → Unit) → T ─────────────────────────────────────
// Context: it=T, returns T. Use: side effects in chain
inline fun <T> T.also(block: (T) -> Unit): T { block(this); return this }
val saved = repository.save(user)
    .also { log.info("Saved: ${it.id}") }  // side effect
    .also { analytics.track(it) }            // another side effect
    // user is passed through unchanged
FunctionContext objectReturn valueExtension?Inline?Best for
applythis = TT (receiver)Object initialization, builder-style configuration
withthis = TR (result)✗ (fn param)Multiple operations on same object, compute result
runthis = TR (result)Config + compute result; null-safe version of with
letit = TR (result)Null checks, rename object, transform to different type
alsoit = TT (receiver)Side effects (logging, tracking) in method chains

@DslMarker — Preventing Scope Leakage

Without @DslMarker, inside a nested DSL block you can accidentally call methods from an outer receiver. This is confusing and a source of bugs. @DslMarker groups receiver types — if the implicit receiver of an outer scope and the current scope share the same marker, the outer receiver's methods are hidden.

@DslMarker — preventing outer scope access
@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class HtmlDsl

@HtmlDsl class Table {
    fun tr(init: Tr.() -> Unit) { Tr().apply(init) }
}
@HtmlDsl class Tr {
    fun td(text: String) { println("<td>$text</td>") }
}
fun table(init: Table.() -> Unit) = Table().apply(init)

table {
    tr {
        td("cell")          // ✓ Tr.td() — correct
        tr { }               // ✗ Error: can't access Table.tr() from Tr scope
        // Without @DslMarker, this would compile and be confusing
    }
}

// Without @DslMarker — the problem:
table {
    tr {
        tr { }    // Would call Table.tr() from inside Tr — confusing!
    }
}

Complete DSL — From Scratch to Usage

Kotlin — Full type-safe HTTP request DSL
// ── 1. Define the DSL marker ─────────────────────────────────────────
@DslMarker @Target(AnnotationTarget.CLASS)
annotation class RequestDsl

// ── 2. Define builder classes ────────────────────────────────────────
@RequestDsl
class HeadersBuilder {
    private val headers = mutableMapOf<String, String>()
    infix fun String.to(value: String) { headers[this] = value }
    fun build() = headers.toMap()
}

@RequestDsl
class RequestBuilder {
    var url: String = ""
    var method: Method = Method.GET
    private var headers: Map<String, String> = emptyMap()
    private var body: String? = null

    fun headers(init: HeadersBuilder.() -> Unit) {
        headers = HeadersBuilder().apply(init).build()
    }
    fun jsonBody(json: String) {
        body = json
        // Can't call headers{} here: @DslMarker prevents it if needed
    }
    fun build() = Request(url, method, headers, body)
}

// ── 3. Entry-point builder function ─────────────────────────────────
fun request(init: RequestBuilder.() -> Unit): Request =
    RequestBuilder().apply(init).build()

// ── 4. Usage: reads like configuration, is fully type-checked ────────
val req = request {
    url = "https://api.example.com/users"
    method = Method.POST

    headers {
        "Authorization" to "Bearer $token"
        "Content-Type"  to "application/json"
        url = "hack"  // ✗ @DslMarker: can't access RequestBuilder.url from HeadersBuilder scope
    }

    jsonBody("""{"name": "Alice", "role": "admin"}""")
}

// ── 5. Gradle-style: mixing receivers and infix operators ────────────
fun dependencies(init: DependencyHandler.() -> Unit) = ...

dependencies {
    implementation("org.jetbrains.kotlin:kotlin-stdlib")
    testImplementation("junit:junit:4.13")
    // Each of these is a method call on DependencyHandler
}
✓ DSL with Receivers — Strengths
+Readable: code reads like natural language or config files
+Type-safe: compiler validates every method call, every assignment — unlike raw strings/maps
+IDE-complete: full autocomplete inside the DSL block — users discover the API via typing
+@DslMarker: nested scopes are enforced at compile time, not by convention
+No runtime cost: all scope functions are inline — zero overhead
✗ DSL — Limitations & Trade-offs
Learning curve: understanding receiver lambdas takes time for newcomers
Error messages: compile errors inside DSL blocks can be cryptic
Over-engineering risk: DSLs are overkill for simple configuration — a data class is often better
No return values from builder blocks: last expression in block is ignored (Unit blocks)
Java interop: DSLs feel alien from Java — the receiver lambda becomes a Function1 with no syntactic sugar
Σ
Complete Reference

Everything, Side by Side

FeatureWhat it isKey rule / constraintMain trade-offUse when
Generics <T>Compile-time type param, erased to Any? at runtimeNo is T or T::class without reifiedSafety vs runtime ignoranceType-safe containers, algorithms
Upper bound T:XConstrains allowed types to subtypes of XWithout bound, T = Any? — call nothingFlexibility vs operations availableAlgorithms needing specific interface
Star projection *Unknown type argument — read Any?, can't writeNot same as Any — no writing allowedSafety at cost of usabilityPrinting, reflection, type checks
out T (covariant)Producer — subtype preservedT only in return positionsFlexibility vs write restrictionRead-only APIs: List, Flow
in T (contravariant)Consumer — subtype reversedT only in param positionsFlexibility vs read restrictionCallbacks, Comparator, Channel.send
Invariant TNo subtype relationshipMust match exactlySafety vs flexibilityMutable containers: MutableList
inline funBody + lambdas pasted at call siteCan't be recursive; binary size growsPerf vs code sizeHOFs with lambdas, reified, hot paths
noinlineSpecific lambda not inlinedLambda stays as FunctionN objectStorability vs allocationLambda must be stored or passed on
crossinlineInlined but no non-local returnLambda called inside another lambdaPerf vs return restrictionAsync wrappers, Handler.post
reified TT substituted at inline call siteOnly on inline; Java can't call itErgonomics vs inline-only constraintType checks, JSON, DI, filterIsInstance
Lambda { }Anonymous function literal as valueUnlabeled return = non-local in inlineConciseness vs return semanticsDefault choice for HOF arguments
fun interfaceKotlin SAM — one abstract methodExactly one abstract methodNamed type vs function type simplicityNamed callbacks, Java interop
Anonymous funfun keyword, no name, local returnreturn always local; no trailing syntaxPredictable returns vs trailing syntaxMulti-return-point, explicit return type
T.() → R (receiver)Lambda where this=T inside@DslMarker needed for nesting safetyReadability vs learning curveBuilders, DSLs, scope functions