Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Advanced Kotlin: Extensions, Generics, Variance, and Delegation

Hello! Last lesson, you gave different responsibilities to Message, Sender, and DispatchOutcome. Those types form a useful model, but a growing program also needs ways to add small behaviors and reuse code without rewriting every class.

This lesson covers four Kotlin tools for that job: extension functions add convenient operations, generics make code work across types, variance governs which generic types can substitute for one another, and delegation lets an object hand work to another object. The examples assume the notification types from the previous lesson are available.


Add a useful operation without changing a class

Suppose a screen needs a short label for a message. That presentation detail need not become a member of Message. An extension function makes it callable with member-like syntax:

fun Message.shortLabel(maxLength: Int): String {
    require(maxLength >= 0)
    return "$channel: ${body.take(maxLength)}"
}

fun demonstrateExtension() {
    val message = Message("recipient-1", "Delivery notice", Channel.SMS)
    println(message.shortLabel(8)) // SMS: Delivery
}

Message before the dot is the receiver type. Inside the function, body and channel refer to properties of the message on which shortLabel was called; you could write this.body if that were clearer.

Watch the introduction and declaration in Kotlin Newbie to Pro - EXTENSION FUNCTIONS - Part 19 by Philipp Lackner. Stop before the video develops its separate prime-number algorithm; the syntax and receiver are what matter here.

Kotlin Newbie to Pro - EXTENSION FUNCTIONS - Part 19

In Kotlin Newbie to Pro - EXTENSION FUNCTIONS - Part 19, Philipp Lackner demonstrates how to declare and call an extension on an existing type.

Watch the motivation for calling a new function on a value, then the declaration. Notice the type before the function name and how the call supplies the receiver.

The syntax looks like an added member, but an extension does not modify its receiver class. It cannot access Message’s private members. Extensions are also selected using the receiver’s declared type, not its runtime subtype; if a member and an applicable extension have the same signature, the member wins. That makes extensions well suited to small, discoverable operations, but not to behavior that must be overridden polymorphically. Keep essential validation inside the class, as you did with Message’s init block.

The official documentation explains those limits and also shows how an extension can have a type parameter. Read the two targeted subsections rather than the entire page.

Extensions | Kotlin Documentation

Read the Kotlin documentation’s Extensions page for generic extension syntax and the rules that distinguish extensions from class members.

In “Generic extension functions,” after the map example, read the list example. Then find “Extension or member functions?” below “Nullable receivers.” Read the dispatch rules, including the shape example between those points. Focus on what the compiler knows at the call site.


Reuse a type-safe design with generics

An extension can work with a particular type, such as Message, or with a family of types. For example, this function returns the middle item of any list, choosing the earlier of the two middle items when its size is even:

fun <T> List<T>.middleOrNull(): T? =
    if (isEmpty()) null else this[(size - 1) / 2]

fun demonstrateGenericExtension() {
    val channels = listOf(Channel.EMAIL, Channel.SMS, Channel.EMAIL)
    val middle: Channel? = channels.middleOrNull()
    println(middle) // SMS
}

The <T> before the function name introduces a type parameter. For a List<Channel>, T is Channel; for a List<Message>, it is Message. The result remains tied to the input type rather than becoming an uninformative Any?.

Classes can have type parameters too. A slot that stores and replaces a value works for many types:

class Slot<T>(var value: T)

fun demonstrateSlot() {
    val messageSlot = Slot(
        Message("recipient-1", "Ready", Channel.EMAIL)
    )
    messageSlot.value = messageSlot.value.copy(body = "Sent")
}

The constructor lets Kotlin infer Slot<Message>. You could spell that type out, but you usually need not. The benefit of Slot<T> is not merely avoiding two similar class definitions: the compiler keeps each slot’s value and assignments consistent.

Before looking at the less intuitive part of generics, read the opening and the variance discussion in the official documentation. Skip its extended Java-wildcard comparison.

Generics: in, out, where | Kotlin Documentation

Read Generics: in, out, where in the Kotlin documentation to connect type parameters with safe substitutions.

At the start of the page, read the introduction and its constructor-inference example. Under “Variance” → “Declaration-site variance,” skip the preceding Java comparison and read the Kotlin rules; continue through the immediately following Comparable example. Finally, under “Type projections” → “Use-site variance: type projections,” examine the Array copy problem and read the projected solution, finishing that sentence just before “Star-projections.” Focus on which operations each projection permits, not the later captured-type details.


Decide when a generic substitution is safe

DispatchOutcome.Accepted is a subtype of DispatchOutcome, but that does not mean Slot<DispatchOutcome.Accepted> is a subtype of Slot<DispatchOutcome>:

val acceptedSlot = Slot(DispatchOutcome.Accepted("receipt-1"))

// Does not compile:
// val outcomeSlot: Slot<DispatchOutcome> = acceptedSlot

If the assignment were allowed, someone could set outcomeSlot.value to Rejected. Reading acceptedSlot.value as an Accepted would then be unsafe. Because Slot<T> both accepts and returns T, it is invariant: the compiler does not allow that widening assignment.

The situation changes when a type only produces or only consumes values. Watch the two short illustrations in Dave Leeds’s Variance... without Generics! for the reason output and input types behave differently.

Variance... without Generics!

Dave Leeds’s Variance... without Generics! builds an intuition for variance using return values and function parameters before applying it to generic types.

Watch narrower outputs: a caller promised a snack can safely receive a candy bar. Then watch broader inputs: something promised to accept any coin cannot suddenly accept only quarters. We will express these two rules with Kotlin type parameters.

In Kotlin, out marks a type parameter used to produce values:

interface Source<out T> {
    fun next(): T
}

class AcceptedSource : Source<DispatchOutcome.Accepted> {
    override fun next() = DispatchOutcome.Accepted("receipt-1")
}

val source: Source<DispatchOutcome> = AcceptedSource()
val outcome: DispatchOutcome = source.next()

This assignment is safe because every Accepted produced by AcceptedSource is a DispatchOutcome. Source<out T> is covariant: a source of the subtype can stand in for a source of the supertype. The out declaration prevents the interface from also taking arbitrary T values as inputs.

Conversely, in marks a type parameter used to consume values:

interface Sink<in T> {
    fun accept(value: T)
}

class OutcomeRecorder : Sink<DispatchOutcome> {
    private val history = mutableListOf<DispatchOutcome>()

    override fun accept(value: DispatchOutcome) {
        history.add(value)
    }

    val size: Int
        get() = history.size
}

fun demonstrateSink() {
    val recorder = OutcomeRecorder()
    val acceptedSink: Sink<DispatchOutcome.Accepted> = recorder

    acceptedSink.accept(DispatchOutcome.Accepted("receipt-2"))
    println(recorder.size) // 1
}

An OutcomeRecorder accepts any DispatchOutcome, so it can certainly accept an Accepted. Sink<in T> is contravariant: a sink of the supertype can stand in for a sink of the subtype. It cannot promise to return a value as T, because an OutcomeRecorder might hold a Rejected instead.

A compact way to remember the distinction is to ask what the caller is allowed to do:

Declaration Role of T Safe substitution
Slot<T> Accept and return Neither direction by default
Source<out T> Return Source of a subtype for source of a supertype
Sink<in T> Accept Sink of a supertype for sink of a subtype

Sometimes a class genuinely needs both operations, yet one use of it needs only reading or only writing. Kotlin’s use-site projections express that narrower promise without redesigning the class:

fun <T> copyFirst(source: Array<out T>, target: Array<in T>) {
    require(source.isNotEmpty() && target.isNotEmpty())
    target[0] = source[0]
}

fun demonstrateProjectedArrays() {
    val accepted = arrayOf(DispatchOutcome.Accepted("receipt-3"))
    val outcomes = arrayOf<DispatchOutcome>(
        DispatchOutcome.Rejected("Earlier attempt")
    )
    copyFirst(accepted, outcomes)
}

Array<T> is invariant because it supports both reading and writing. In copyFirst, however, source is viewed only as a producer and target only as a consumer. The function can therefore copy an Accepted into an array capable of holding any DispatchOutcome, without gaining permission to put a Rejected into the accepted-only array. The require check handles the separate issue of an empty array.


Hand implementation work to a delegate

Generics help you reuse type-safe designs. Delegation helps you reuse behavior. Recall that Sender has a send function and PreviewSender implements it. Suppose you want to attach a label to a sender without changing either type:

class LabeledSender(
    sender: Sender,
    val label: String
) : Sender by sender

fun demonstrateClassDelegation() {
    val labeled = LabeledSender(PreviewSender(), "preview")
    val message = Message("recipient-1", "Delivery notice", Channel.SMS)

    println(labeled.label)                   // preview
    println(describe(labeled.send(message))) // Accepted: preview
}

LabeledSender promises to implement Sender. The by sender clause tells Kotlin to forward its Sender members to the supplied object, so you do not have to write a forwarding send method. You can still explicitly override a delegated method if the wrapper needs different behavior. This is class delegation: one object satisfies an interface by using another object’s implementation.

Kotlin also supports property delegation. Here, creating a sender is deferred until the property is first accessed:

class SenderService {
    val sender: Sender by lazy {
        println("Creating preview sender")
        PreviewSender()
    }
}

fun demonstratePropertyDelegation() {
    val service = SenderService() // No sender created yet
    println(service.sender === service.sender) // true
}

The lazy delegate computes the value on first access and retains it for later accesses. The printed initialization message appears once. The same by keyword is involved, but the job differs: class delegation forwards interface behavior, whereas property delegation supplies the behavior behind a property’s access. lazy is one built-in delegate; the important choice here is when the sender should be constructed, not a new kind of Sender.


Key takeaways

Use an extension function for an operation that belongs naturally in call-site syntax but need not live inside a type. Use a generic type parameter when the same code should preserve the caller’s specific type. For generic substitution, check whether the type produces values (out), consumes them (in), or does both (invariant); use a projection when a particular function needs a restricted view of an invariant type. Use delegation when you want to forward an interface implementation or give a property a reusable access strategy.

Next, you’ll put these tools to work with Kotlin collections, lambdas, and higher-order functions to transform groups of values rather than one value at a time.

Can't find a good explanation? Sign up and we'll make it for you

Sign up