Kotlin Internals · Advanced Type System · Vol. 05

Generics,
Lambdas &
Functions

A complete system-level guide to Kotlin's type system features — from how generics are erased at runtime and why reified escapes that, to the difference between a lambda, a SAM, and an anonymous function, to building type-safe DSLs with receivers.

01Generics — Type Parameters, Constraints, Erasure
02Variance — in, out, invariant + use-site
03Inline Functions — noinline, crossinline, performance
04Reified — type access at runtime, no reflection
05Lambda — syntax, trailing, capturing, types
06SAM Conversions — Java interop, fun interface
07Anonymous Functions — return semantics
08DSL — receivers, builders, type-safe APIs
T
Type Parameters
Erased at runtime to Any? — but reified inline functions preserve them during compilation.
in/out
Variance Keywords
Covariance (out) and contravariance (in) control whether generic types are subtypes of each other.
0
Object Allocations for Inline Lambdas
The compiler pastes lambda body at call site — no Function object, no virtual dispatch overhead.
01
Type System Foundation

Generics & Type Erasure

Kotlin generics, like Java's, are erased at runtime. The JVM bytecode does not know that List<String> is different from List<Int> — both become raw List. This is a deliberate JVM design decision for backward compatibility, but it has significant implications: you cannot do list is List<String> at runtime, and you cannot call T::class inside a generic function.

Type parameters serve the compiler — they enforce correctness at call sites, enable overload resolution, and prevent you from putting an Int into a List<String>. But by the time the JVM runs your code, all that information is gone.

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

Upper Bounds & Multiple Constraints

Use T : SomeType to constrain what types are allowed. Multiple constraints require a where clause. The constraint narrows what methods you can call on T — without it, T is effectively Any? and you can call nothing useful.

Kotlin — Generics fundamentals
// Basic type parameter
fun <T> identity(value: T): T = value

// Upper bound: T must implement Comparable<T>
fun <T : Comparable<T>> max(a: T, b: T): T =
    if (a > b) a else b

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

// Generic class with bounded type parameter
class Repository<T : Entity> {
    private val cache = mutableListOf<T>()
    fun save(item: T) = cache.add(item)
    fun findById(id: Long): T? =
        cache.firstOrNull { it.id == id }
}

// TYPE ERASURE — this doesn't compile:
fun <T> isString(value: Any): Boolean {
    return value is T  // ✗ Cannot check erased type
}

// At runtime, T is erased:
// List<String> == List<Int> == List (raw)
// Solution: reified (see §04)

// Star projection — when T is unknown
fun printSize(list: List<*>) {
    println(list.size) // OK: size doesn't need T
    // list.add("x")  ✗ Can't add — type unknown
}

Generic Functions vs Generic Classes — When to Use Each

Generic Function

Per-call type inference

Type parameter resolved at each call site. Good for utilities like listOf(), filterIsInstance(), map(). Type is scoped to the single call.

Generic Class

Per-instance type binding

Type parameter fixed when you create the instance: Repository<User>. All methods on that instance use the same bound type. Good for containers, repositories, state holders.

Generic Interface

Contract with type flexibility

Implementing classes choose the type: class UserRepo : Repository<User>. Enables type-safe polymorphism across a family of implementations with a shared contract.

02
Type Relationships

Variance: in, out, Invariant

Variance describes how the subtype relationship between generic types relates to the subtype relationship between their type arguments. It's one of the trickiest parts of any type system — and Kotlin gives you more precise control than Java.

PRODUCER
Covariant
out T

If Dog : Animal then Producer<Dog> : Producer<Animal>.

Can only return T, never accept T. Think: a factory that produces Dogs — you can use it anywhere an Animal factory is expected, because every Dog is an Animal.

List<out T> in Kotlin stdlib. You can read from it but not write to it.

CONSUMER
Contravariant
in T

If Dog : Animal then Consumer<Animal> : Consumer<Dog> — reversed!

Can only accept T, never return T. A consumer of Animals can consume Dogs — so it's usable as a Dog consumer. Think: a comparator of Animals works for Dogs.

Comparator<in T>. You can write to it but not read from it.

DEFAULT
Invariant
T (no modifier)

MutableList<Dog> is NOT a subtype of MutableList<Animal>.

Can both read and write T. Because you can write to it, the type must be exactly right — allowing a MutableList<Dog> as a MutableList<Animal> would let you insert a Cat, breaking type safety.

Kotlin — Declaration-site vs Use-site variance
// ── DECLARATION-SITE VARIANCE ──────────────────────────────
// 'out' on the class: Covariant producer — T only in return positions
class Producer<out T>(private val value: T) {
    fun get(): T = value           // ✓ OK: T in return position
    // fun set(v: T) { ... }        ✗ Error: T in parameter position
}
// Now this compiles:
val dogProducer: Producer<Dog> = Producer(Dog())
val animalProducer: Producer<Animal> = dogProducer  // ✓ covariant

// 'in': Contravariant consumer — T only in parameter positions
interface Consumer<in T> {
    fun consume(item: T)           // ✓ OK: T in parameter position
    // fun produce(): T             ✗ Error: T in return position
}
val animalConsumer: Consumer<Animal> = object : Consumer<Animal> {
    override fun consume(item: Animal) { println(item) }
}
val dogConsumer: Consumer<Dog> = animalConsumer   // ✓ contravariant

// ── USE-SITE VARIANCE (type projection) ───────────────────
// When you can't change the class (e.g., MutableList)
fun copyFrom(src: MutableList<out Animal>) {  // read-only projection
    val first = src[0]  // ✓ can read Animal
    // src.add(Dog())     ✗ can't write — type projected out
}
fun fillWith(dst: MutableList<in Dog>, count: Int) {  // write-only projection
    repeat(count) { dst.add(Dog()) }  // ✓ can write Dog
    // val d: Dog = dst[0]  ✗ reads back as Any?
}

// ── STAR PROJECTION ──────────────────────────────────────
// List<*> ≈ List<out Any?>: can read Any?, can't write anything useful
fun printAll(list: List<*>) = list.forEach { println(it) }
💡

The Producer-Consumer Mnemonic (PECS)

From Java: Producer Extends, Consumer Super. In Kotlin: Producer = out, Consumer = in. If a type only produces T (returns it), use out. If it only consumes T (takes it as parameter), use in. If it does both, it must be invariant (no modifier).

03
Compiler Optimization

Inline Functions & Modifiers

When you pass a lambda to a regular function, Kotlin compiles it into a Function object — a heap allocation on every call. In hot paths (loops, collection operations, Compose), this is significant overhead. The inline keyword tells the compiler to paste the function body — and the lambda body — at every call site. No object, no virtual dispatch, no allocation.

This is also what makes reified possible: when the compiler inlines the function body, it knows the exact type at the call site and can substitute it in the pasted code — whereas a non-inline function receives type parameters that are already erased.

⚠️

Inline Increases Bytecode Size

Each call site gets a full copy of the inlined function body. For large functions called many times, this inflates bytecode significantly. Reserve inline for: small functions taking lambdas, hot loops, functions needing reified, and non-local return scenarios.

Modifier
Effect
When to Use
Non-local return?
inline
Entire function + all lambda params inlined at call site. No Function object allocated.
Small HOFs taking lambdas, hot paths, reified generics
✓ Allowed
noinline
Specific lambda parameter is NOT inlined — stays as a Function object. Can be stored or passed to non-inline code.
When you need to store the lambda in a property or pass it to another non-inline function
✗ Blocked
crossinline
Lambda IS inlined, but non-local returns are forbidden. Allows the lambda to be called in a different execution context (inside a Runnable, coroutine, etc).
When the lambda will be invoked in another lambda or callback context — not directly in the inlined body
✗ Blocked
Kotlin — inline, noinline, crossinline
// INLINE: lambda body pasted at call site
inline fun <T> measureTime(block: () -> T): T {
    val start = System.nanoTime()
    val result = block()          // ← inlined — no Function object
    println("${System.nanoTime() - start}ns")
    return result
}
// Non-local return: return from the CALLING function
fun findFirst(list: List<Int>): Int? {
    list.forEach {         // forEach is inline
        if (it > 10) return it  // returns from findFirst, not forEach!
    }
    return null
}

// NOINLINE: this lambda stays as a Function object
inline fun withHandler(
    noinline onError: (Exception) -> Unit,  // stored, not inlined
    block: () -> Unit                         // inlined
) {
    handler.onError = onError  // can store noinline lambda
    try { block() } catch (e: Exception) { onError(e) }
}

// CROSSINLINE: inlined but blocks non-local returns
inline fun runOnMain(crossinline block: () -> Unit) {
    handler.post {         // block invoked inside a Runnable
        block()            // ← crossinline prevents non-local return
    }                       // (can't return from outer function from here)
}

// Compiler output — what inline generates at call site:
// measureTime { doWork() }
// → val start = System.nanoTime()
// → val result = doWork()          ← body pasted
// → println("${System.nanoTime() - start}ns")
04
Runtime Type Access

Reified Type Parameters

reified is only usable on inline functions. It solves the type erasure problem: because the function body is pasted at each call site, the compiler knows the exact type argument at that specific call. It substitutes the real type into the pasted code — so T acts like a real class at runtime.

This enables: T::class, value is T, and typeOf<T>() inside inline functions — things that are impossible in normal generic functions because T is erased before the JVM sees it.

"Reified is not magic — it's the compiler doing the substitution you'd have to do manually by passing KClass<T> as a parameter."

Kotlin — reified patterns
// ✗ Without reified: T is erased, must pass KClass manually
fun <T> findService(clazz: KClass<T>): T { ... }
findService(UserService::class)  // caller must pass KClass

// ✓ With reified: T is available as a real class
inline fun <reified T> findService(): T {
    return serviceLocator.get(T::class.java) as T
}
findService<UserService>()  // clean, no KClass argument

// Type checking at runtime
inline fun <reified T> Any.isType() = this is T
// Compiles to: this is UserService (substituted at call site)

// filterIsInstance — stdlib uses reified internally
inline fun <reified R> Iterable<*>.filterIsInstance(): List<R> =
    filter { it is R }.map { it as R }

// JSON/serialization: get type without passing Class
inline fun <reified T> parseJson(json: String): T =
    gson.fromJson(json, T::class.java)

// Android ViewModel without ViewModelProvider boilerplate
inline fun <reified VM : ViewModel> Fragment.viewModels(): VM =
    ViewModelProvider(this)[VM::class.java]

// What the compiler generates at call site:
// parseJson<User>(json)
// → gson.fromJson(json, User::class.java)  ← T substituted
05
First-Class Functions

Lambdas — All Forms & Syntax

A lambda in Kotlin is an anonymous function literal that can be stored in a variable, passed as an argument, or returned. Kotlin's type system represents lambdas as function types: (A, B) -> C. Every lambda is compiled to a class implementing the corresponding FunctionN interface — unless it's inlined.

Full Syntax
{ a: Int, b: Int -> a + b }
Explicit parameter types, body after arrow. Always works.
Trailing Lambda
func(x) { body }
If last param is a lambda, move it outside the parens. Core to Kotlin's DSL feel.
it — Single Param
{ it * 2 }
When there's exactly one parameter and its type is inferred, use implicit it.
Member Reference
String::uppercase
Reference to a function without invoking it. Interchangeable with a matching lambda.
Lambda with Receiver
T.() -> R
Inside the lambda, this refers to the receiver. Foundation of DSL builders.
Kotlin — Lambda syntax forms
// Function type variables
val add: (Int, Int) -> Int = { a, b -> a + b }
val greet: (String) -> Unit = { println("Hello, $it") }
val produce: () -> String = { "hello" }

// Trailing lambda syntax — cleaner API
repeat(3) { i -> println(i) }       // preferred
repeat(3, { i -> println(i) })     // equivalent, ugly

// Multi-line lambda — last expression is the return value
val process: (String) -> Int = {
    val trimmed = it.trim()
    val upper = trimmed.uppercase()
    upper.length                   // ← implicit return
}

// Capturing: lambdas close over outer variables
var count = 0
val increment = { count++ }       // captures 'count' by reference
increment(); increment()
println(count)  // 2 — variable was mutated

// Lambda with receiver — 'this' is a StringBuilder
val builder: StringBuilder.() -> Unit = {
    append("Hello ")  // 'this' is StringBuilder
    append("World")
}
val result = StringBuilder().apply(builder)

// Function type with receiver vs extension function:
fun StringBuilder.addGreeting() = append("Hi")  // extension function
val addGreeting: StringBuilder.() -> Unit = { append("Hi") } // same!

// Underscore for unused params
map.forEach { (_, value) -> println(value) }

Lambda Compilation: The Cost

Every non-inlined lambda creates a FunctionN object on the heap. Capturing variables is more expensive than non-capturing ones: a capturing lambda creates a new object per call; a non-capturing lambda is compiled to a singleton.

Kotlin — Lambda compilation internals
// Non-capturing lambda → compiled to a SINGLETON
val pure = { x: Int -> x * 2 }
// Bytecode: static final Function1 pure = new Function1() { ... }
// Called 1000 times → 0 extra allocations

// Capturing lambda → NEW OBJECT per invocation context
fun makeAdder(n: Int) = { x: Int -> x + n }
// Bytecode: new Function1(n) { ... } — captures n in field
// makeAdder(5) and makeAdder(10) create different objects

// Inline lambda → NO object at all
inline fun twice(block: () -> Unit) { block(); block() }
twice { doWork() }
// Bytecode: doWork(); doWork()  ← body pasted, no Function object

// Function references — always singletons for top-level
val ref = ::println        // singleton
val method = "hi"::length  // bound reference — holds 'this'
06
Single Abstract Method

SAM Conversions & fun interface

SAM (Single Abstract Method) conversion lets you pass a lambda where a functional interface is expected — an interface with exactly one abstract method. Java has had this since lambdas arrived in Java 8 (Runnable, Comparator, OnClickListener). Kotlin extends this with fun interface for defining your own SAM types.

The key difference between a SAM interface and a plain function type is identity: a fun interface has a named type that can be used in Java interop, can have non-abstract methods, and can implement other interfaces. A plain (T) -> R function type is anonymous and cannot.

"Use fun interface when you need a named contract. Use a function type when you just need a callable value."

Kotlin — SAM & fun interface
// Java SAM — Runnable has one abstract method: run()
val runnable: Runnable = Runnable { println("Running") }
// Or more concisely (SAM conversion):
val runnable2: Runnable = { println("Running") }
executor.execute { println("Running") }  // trailing SAM conversion

// Kotlin fun interface — explicit SAM contract
fun interface Transformer<T, R> {
    fun transform(input: T): R         // single abstract method
    fun isIdentity(): Boolean = false  // non-abstract method OK
}
// Lambda converts to Transformer via SAM:
val double: Transformer<Int, Int> = { it * 2 }
println(double.transform(5))  // 10

// fun interface vs function type — the difference:
fun interface Validator { fun validate(s: String): Boolean }
typealias ValidatorFn = (String) -> Boolean

// fun interface: named type, usable in Java, can have default impls
val v1: Validator = { it.isNotEmpty() }
// function type: lighter, no named wrapper
val v2: ValidatorFn = { it.isNotEmpty() }

// SAM with Android View.OnClickListener
button.setOnClickListener { view ->   // SAM conversion
    // Java OnClickListener.onClick(View) ← single abstract method
    println("Clicked: $view")
}
07
Function Literals

Anonymous Functions vs Lambdas

An anonymous function is a function literal written with the fun keyword but without a name. It looks like a regular function definition that you can assign or pass. The critical distinction from a lambda is return semantics: a return inside an anonymous function returns from the anonymous function itself, not from the enclosing function.

In a lambda, an unlabeled return is a non-local return — it returns from the enclosing named function (only valid in inline lambdas). This sometimes causes surprising behavior. Anonymous functions give you predictable local-return semantics without needing labels.

Kotlin — Anonymous function syntax & return semantics
// Anonymous function syntax
val double = fun(x: Int): Int { return x * 2 }
val greet = fun(name: String) = "Hello, $name"  // expression form

// Explicit return type (can't do this in a lambda)
val divide = fun(a: Int, b: Int): Double? {
    if (b == 0) return null  // returns from anonymous function
    return a.toDouble() / b
}

// ── KEY DIFFERENCE: return semantics ─────────────────────
fun demo(items: List<Int>) {
    // Lambda: return is NON-LOCAL (returns from 'demo'!)
    items.forEach {
        if (it == 0) return  // ← exits 'demo', not just forEach
    }

    // Lambda with label: local return from lambda
    items.forEach forEachLabel@{
        if (it == 0) return@forEachLabel  // ← exits forEach iteration only
    }

    // Anonymous function: return is always LOCAL
    items.forEach(fun(item: Int) {
        if (item == 0) return  // ← exits anonymous fun only (same as label)
        println(item)
    })
}

// When to prefer anonymous function over lambda:
// 1. Need explicit return type declaration
// 2. Multiple return points with clear local semantics
// 3. Inside non-inline HOFs where non-local return isn't possible anyway
// 4. When labeleled returns feel unreadable
Feature Lambda Anonymous Function
Syntax{ params -> body }fun(params): Type { body }
Return typeInferred from last expressionExplicit declaration allowed
Unlabeled returnNon-local (exits enclosing fn)Local (exits anonymous fn only)
Trailing syntaxYes — can be outside parensNo — must be inside parens
Implicit itYes — single paramNo — must name params
Multiple return pointsNeeds labeled returnsNatural with return
08
Builder Pattern Evolved

Type-Safe DSLs with Receivers

Kotlin's DSL capabilities come from lambdas with receivers — function types of the form T.() -> Unit where inside the lambda, this is of type T. You call it like a member of T without explicit qualification. This creates a scoped context where only relevant operations are available.

Combined with @DslMarker — an annotation that prevents accessing outer receiver methods from an inner scope — you get compile-time enforcement of DSL nesting rules. This is how Kotlin HTML DSLs (kotlinx.html), test DSLs, Gradle Kotlin DSL, and Ktor routing are built.

"A DSL builder is just a function that takes a lambda with receiver, calls it on a freshly-created object, and returns that object."

Kotlin — DSL builder pattern
// The pattern: builder function + receiver lambda
class HtmlBuilder {
    private val children = mutableListOf<String>()
    fun p(text: String) = children.add("<p>$text</p>")
    fun h1(text: String) = children.add("<h1>$text</h1>")
    fun build() = children.joinToString("\n")
}

// Builder function: receiver lambda invoked on a new HtmlBuilder
fun html(block: HtmlBuilder.() -> Unit): String {
    val builder = HtmlBuilder()
    builder.block()      // 'this' inside block = builder instance
    return builder.build()
}

// Usage: reads like HTML, is fully type-safe Kotlin
val page = html {
    h1("Welcome")   // calls HtmlBuilder.h1()
    p("Hello world") // calls HtmlBuilder.p()
}

// @DslMarker: prevent accessing outer scope from inner scope
@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class HtmlDsl

@HtmlDsl class Div { fun p(text: String) { ... } }
@HtmlDsl class Table { fun div(init: Div.() -> Unit) { ... } }

// Without @DslMarker, outer div's methods leak into inner scope.
// With @DslMarker, this is a compile error:
table {
    div {
        tr { ... }  // ✗ Error: can't access table's tr() from div scope
    }
}

apply, with, run, let, also — The Stdlib DSL Primitives

The Kotlin stdlib's scope functions are all DSL-builder-pattern functions. Understanding them demystifies both scope functions and DSLs at once.

Kotlin — Scope functions as DSL primitives
// apply: T.(block) → T  — receiver = this, returns receiver
// Use: object configuration (builder pattern)
val dialog = AlertDialog.Builder(ctx).apply {
    setTitle("Warning")  // this = Builder
    setMessage("Are you sure?")
}.create()

// with: (T, T.() → R) → R  — receiver = this, returns result
// Use: multiple operations on same object, result is different
val size = with(myList) {
    add("item")   // this = myList
    size          // return value
}

// let: T.((T) → R) → R  — argument = it, returns result
// Use: null-safe chain, transform and return something else
name?.let { println("Name: $it") }  // only runs if non-null

// run: T.(T.() → R) → R  — receiver = this, returns result
// Use: object config + compute result in same block
val result = builder.run {
    configure()    // this = builder
    build()        // return build result
}

// also: T.((T) → Unit) → T  — argument = it, returns receiver
// Use: side effects in a chain (logging, validation)
val user = createUser()
    .also { log.info("Created: ${it.id}") }  // side effect
    .also { analytics.track(it) }

Real-World DSL: Type-Safe HTTP Client

Kotlin — Building a type-safe DSL from scratch
// Define your builder classes
@DslMarker
@Target(AnnotationTarget.CLASS)
annotation class RequestDsl

@RequestDsl
class RequestBuilder {
    var url: String = ""
    var method: String = "GET"
    private val headers = mutableMapOf<String, String>()
    private var body: String? = null

    fun header(key: String, value: String) {
        headers[key] = value
    }
    fun body(init: BodyBuilder.() -> Unit) {
        body = BodyBuilder().apply(init).build()
    }
    fun build() = Request(url, method, headers, body)
}

// The entry point builder function
fun request(init: RequestBuilder.() -> Unit): Request =
    RequestBuilder().apply(init).build()

// Usage: reads like configuration, is fully type-checked
val req = request {
    url = "https://api.example.com/users"
    method = "POST"
    header("Authorization", "Bearer $token")
    body {
        json { "name" to "Alice" }
    }
}
Σ
Reference

The Complete Mental Model

Feature What it is Key constraint / rule Primary use case
Generics <T> Type parameter — compile-time only, erased to Any? at runtime Cannot do is T or T::class at runtime without reified Type-safe containers, algorithms, repositories
out T Covariant: Producer<Dog> is Producer<Animal> T only in return/out positions — never as a parameter Read-only producers: List, Flow, Channel receive
in T Contravariant: Consumer<Animal> is Consumer<Dog> T only in parameter/in positions — never as return type Write-only consumers: Comparator, Channel send
inline fun Compiler pastes body + lambda body at every call site Binary size grows; avoid for large or rarely-called functions HOFs taking lambdas, reified generics, non-local returns
noinline Specific lambda param excluded from inlining Must be used if lambda is stored or passed to non-inline context Storing callbacks, passing to async APIs
crossinline Lambda is inlined but non-local return forbidden Use when lambda is invoked inside another lambda in the body runOnMain, post { }, async wrappers
reified T T is substituted at call site — type survives "erasure" Only on inline functions; cannot be used with non-inline filterIsInstance, JSON parsing, ViewModel factory, DI
Lambda { } Anonymous function literal, compiled to FunctionN class Unlabeled return is non-local (exits enclosing function) HOF arguments, callbacks, collection operations
fun interface Kotlin SAM — interface with one abstract method + lambda conversion Exactly one abstract method; can have default implementations Named functional contracts, Java interop, event callbacks
Anonymous fun Function literal with fun keyword — local return semantics return exits the anonymous function, not the enclosing one Multiple return points without labels, explicit return types
DSL / T.() -> R Lambda with receiver — this = T inside the block @DslMarker prevents outer scope leakage into nested contexts Builders, configuration, HTML/HTTP/test DSLs, Gradle