6 Underrated Kotlin Features That Can Transform Your Code in 2025

Table of contents
- 🛠️ 1. Context Receivers: Writing Functions That Make Sense in Context
- 🧾 2. Kotlin Contracts: Make Your Code Smarter with Compiler-Assisted Guarantees
- 🧙 3. The Secret Sauce Behind Beautiful DSLs: @BuilderInference
- 🧱 4. Sealed Interfaces + Smart when : Exhaustive, Safer, and Cleaner
- 🖼️ 5. Making Compose APIs Cleaner with Function References & Receiver Lambdas
- ⚡ 6. Value Classes: Boost Performance with Zero-Overhead Wrappers
- 🧩Conclusion

Kotlin has come a long way in the last couple of years. From being the new kid on the block to becoming the official language for Android development, it has constantly evolved with powerful features that help developers write cleaner, safer, and more efficient code.
Previously, I published an article on advanced Kotlin features that resonated with many experienced developers. Since then, the language and ecosystem have grown significantly, unlocking even more hidden gems that remain largely underappreciated, even by senior Kotlin developers.
In this sequel, I'll take you through some of the most undervalued Kotlin features and techniques that can supercharge your productivity and improve your code quality. These are not the typical Kotlin features everyone talks about; instead, these are the tools and tricks that most developers overlook, yet can make a big difference in day-to-day coding and project architecture.
So buckle up, and let's dive into the Kotlin features that deserve more attention in 2025 and beyond.
Let’s kick things off with a feature that’s changing how we write cleaner APIs...
🛠️ 1. Context Receivers: Writing Functions That Make Sense in Context
If you think you know Kotlin inside out, think again. One of the coolest yet least used features introduced recently is Context Receivers. This feature lets you write cleaner, more modular code by explicitly declaring the context your functions or classes need, without cluttering your method signatures or passing extra parameters everywhere.
📌What are Context Receivers?
Imagine you have multiple “context” objects (like a database connection, a logger, or a configuration) that many functions need to access. Traditionally, you'd either pass these as parameters repeatedly or rely on global singletons -both messy and error-prone approaches.
With Context Receivers, Kotlin allows you to declare those contexts explicitly at the function or class level. This means you can use members of these context objects directly inside your functions, without passing them as parameters.
🤔Why should you care?
Reduces boilerplate in large codebases where many functions depend on the same context objects
Improves code readability by making the dependencies explicit and scoped
Enables better modularity and easier testing
👨💻How to use?
class Logger {
fun log(message: String) = println("Log: $message")
}
class Database {
fun query(sql: String) = "Result of '$sql'"
}
context(Logger, Database)
fun fetchData() {
log("Fetching data from database")
val result = query("SELECT * FROM users")
log("Query result: $result")
}
fun main() {
val logger = Logger()
val database = Database()
with(logger) {
with(database) {
fetchData()
}
}
}
In this example, the fetchData()
function explicitly declares that it requires Logger
and Database
contexts. Inside the function, you can directly call log()
and query()
without passing them as parameters.
🧪Real-world use cases
Building DSLs where multiple context objects are needed, but you want clean, readable syntax
Services or repositories accessing several shared resources, avoiding messy parameter passing
UI components that require multiple context-like objects (theme, resources, handlers) in a clean, scoped way
🎯Mastering context receivers today will put you ahead as Kotlin continues evolving -it's a smart investment in your toolkit.
🧾 2. Kotlin Contracts: Make Your Code Smarter with Compiler-Assisted Guarantees
When you write Kotlin, you probably rely on standard null checks, smart casts, and conditions. But did you know Kotlin allows you to define contracts? Contracts give the compiler additional information about how your functions behave, enabling smarter code analysis, better optimization, and safer nullability checks.
🧠What are Kotlin Contracts?
Contracts are a way to describe the behavior of functions beyond their signatures, specifically how they affect control flow, argument nullability, and exceptions. They let the compiler understand conditions like:
“If this function returns, then this parameter is guaranteed not to be null.”
“This lambda is invoked exactly once.”
“This function throws an exception under specific conditions.”
By declaring contracts, you empower the compiler to perform better smart casting, eliminate unnecessary checks, and warn about incorrect usage early.
😕Why do most developers miss this?
Contracts are still an experimental feature and require explicit enabling. Because they are underused, many senior developers don't even realize the power they can unlock, which means their code misses out on helpful compiler assistance.
⚙️How to enable Kotlin Contracts
Add the opt-in annotation in your build setup:
@OptIn(ExperimentalContracts::class)
fun someFunction() {
// function body
}
Or enable it globally via compiler arguments in your Gradle configuration.
Add the following compiler argument to your build.gradle
:
kotlin {
sourceSets.all {
languageSettings {
optIn("kotlin.contracts.ExperimentalContracts")
}
}
}
This opt-in ensures your entire module can use contracts without repeating the annotation on every function.
🚫Typical Code Without Contracts
Let's say you're writing a utility to validate a nullable input and continue only if it's non-null:
fun requireNonNull(value: String?) {
if (value == null) throw IllegalArgumentException("Value cannot be null")
}
fun useValue(value: String?) {
requireNonNull(value)
println(value.length) // ⚠️ Smart cast doesn't work here!
}
Kotlin doesn't smart cast value
here - because it doesn't know that requireNonNull()
guarantees non-null on return.
✅With Kotlin Contracts
Now watch this:
import kotlin.contracts.*
@OptIn(ExperimentalContracts::class)
fun requireNonNull(value: Any?) {
contract {
returns() implies (value != null)
}
if (value == null) throw IllegalArgumentException("Value cannot be null")
}
fun demo(value: Any?) {
requireNonNull(value)
// After requireNonNull, Kotlin smart casts 'value' to non-null automatically
println(value.hashCode()) // No need for null checks here
}
With the contract in place, Kotlin's compiler now understands the condition and treats value
as non-null after the call.
💡Use cases where contracts shine
Custom assertions and validation functions
Control flow helpers that influence program execution paths
Lambda invocation control (how many times a lambda is called, etc.)
Optimizing code for safety and readability by reducing boilerplate null checks or redundant conditionals
🧙 3. The Secret Sauce Behind Beautiful DSLs: @BuilderInference
Ever wondered how Jetpack Compose or kotlinx HTML manages to infer types so well in deeply nested builders? The answer lies in one of Kotlin's most underused yet powerful tools -the @BuilderInference
annotation.
Most Kotlin developers either misuse this or don't even know it exists. But once you get it, you'll start writing DSLs and extension lambdas like a magician.
⚠️The Problem: Type Inference Falls Apart in Lambdas
Let's say you want to build a generic utility that creates a list using a builder pattern:
fun buildSmartList(builder: MutableList<T>.() -> Unit): List {
return buildList { builder() }
}
Now try using it like this:
val items = buildSmartList {
add("Hello")
add("World")
}
🔴 Uh-oh, the Kotlin compiler can't infer what T
is. You'll get this:
“Cannot infer type parameter T."
Why? Because Kotlin doesn't perform type inference deeply enough inside lambda receivers when generics are involved.
💡The Fix: Add @BuilderInference
Here's how to make Kotlin smarter:
fun buildSmartList(
@BuilderInference builder: MutableList<T>.() -> Unit
): List {
return buildList { builder() }
}
Now you can call it like this, without specifying any type:
val items = buildSmartList {
add("Kotlin")
add("Rocks")
}
✅ This works perfectly. Kotlin now infers T
as String
.
🎯Where This Shines
This is incredibly useful when building domain-specific languages (DSLs), especially if you want to allow users to define behavior using intuitive syntax without fighting the type system.
You've probably used it unknowingly if you've ever written:
LazyColumn {
items(listOf("A", "B")) { item ->
Text(text = item)
}
}
Internally, Compose uses @BuilderInference
on many of these lambda receivers to infer types smartly.
🧱 4. Sealed Interfaces + Smart when
: Exhaustive, Safer, and Cleaner
Most developers know about sealed class
in Kotlin, it's one of the cleanest ways to model restricted hierarchies. But did you know Kotlin now supports sealed interfaces too? And that you can use them in powerful new ways to enforce exhaustiveness in when
blocks, even across multiple hierarchies?
Let's dig into this, because it can seriously clean up your business logic and reduce runtime bugs.
📘The Basics: What's a Sealed Interface?
A sealed interface
lets you define an interface that can only be implemented by a fixed set of types, just like how a sealed class restricts inheritance.
sealed interface UiState
data class Loading(val message: String) : UiState
data class Success<T>(val data: T) : UiState
data class Error(val throwable: Throwable) : UiState
This means that UiState
can only be one of Loading
, Success
, or Error
. No more. Not even by accident.
🔄Why Use a Sealed Interface Instead of a Sealed Class?
Multiple inheritance: You can implement a sealed interface along with other interfaces or classes.
Cleaner modeling: If you're defining behavior more than state,
interface
is a better fit.Better testability: Interfaces are naturally easier to mock or substitute during testing.
⚠️What Developers Often Do (And Why It's Risky)
Here's a common pattern seen in older Kotlin code:
interface UiState
class Loading : UiState
class Success : UiState
class Error : UiState
fun handle(state: UiState) {
when (state) {
is Loading -> { /* show spinner */ }
is Success -> { /* show data */ }
is Error -> { /* show error */ }
else -> { /* this should never happen... */ }
}
}
Notice that ugly else
? It's mandatory because the compiler can't guarantee exhaustiveness on open interfaces.
This means: if someone adds a new implementation tomorrow, and forgets to update the when
- boom. Silent logic failure.
✅How Kotlin Sealed Interface Fixes This
With sealed interfaces, you now get compile-time exhaustiveness:
fun handle(state: UiState) = when (state) {
is Loading -> { /* show spinner */ }
is Success -> { /* show data */ }
is Error -> { /* show error */ }
// ❌ no need for else - compiler ensures we handled all cases
}
Much better, right?
🔥Real-World Use Case: Combining Sealed Interface with MVI
Imagine modeling screen states in a robust MVI architecture:
sealed interface ScreenState
object Loading : ScreenState
data class Content(val data: List<String>) : ScreenState
data class Error(val message: String) : ScreenState
Now, in your Composable:
@Composable
fun Screen(state: ScreenState) {
when (state) {
is Loading -> ShowLoading()
is Content -> ShowContent(state.data)
is Error -> ShowError(state.message)
}
}
No risk of accidentally forgetting to handle a new screen state. The compiler has your back.
🤯Bonus: Nested Sealed Interfaces with Smart when
Kotlin 1.8+ even allows nested sealed interfaces -combine this with smart when
expressions, and you get some seriously expressive modeling. For example:
sealed interface NetworkResult {
sealed interface Success : NetworkResult {
data class Data(val content: String) : Success
object Empty : Success
}
data class Failure(val reason: String) : NetworkResult
}
This allows you to precisely structure the outcomes of a network request and enforce handling at each level.
🧠 Pro tip: Use
sealed interface
when you're defining behavior categories, andsealed class
when your types share common properties.
🖼️ 5. Making Compose APIs Cleaner with Function References & Receiver Lambdas
Jetpack Compose's declarative UI shines when your code is clean and easy to read. Two powerful yet often underused Kotlin features - function references and lambdas with receivers - can help you write Compose code that's more concise and expressive.
🔗What are Function References?
Function references let you pass functions around as first-class citizens without explicitly writing lambda expressions every time.
Instead of writing this:
Button(onClick = { onClickHandler() }) {
Text("Click Me")
}
You can use a function reference:
Button(onClick = onClickHandler) {
Text("Click Me")
}
This small change improves readability and avoids unnecessary lambda overhead.
📦Lambdas with Receivers in Compose
Lambdas with receivers let you write blocks of code where the receiver object becomes the implicit this
. This is heavily used in Compose DSLs for building UI trees.
For example:
Column {
Text("Hello")
Button(onClick = { /*...*/ }) {
Text("Click Me")
}
}
Here, the Column
's lambda has a receiver of type ColumnScope
, so you can call UI functions directly inside it.
🔄Combining Both for Cleaner APIs
Sometimes Compose APIs expect lambdas with receivers, but you want to reuse existing functions that don't match the signature exactly. Kotlin's feature called function references with receivers (or context receivers) can bridge this gap, allowing you to pass function references where lambdas with receivers are expected.
This helps avoid verbose inline lambdas and makes your composable calls cleaner.
🧑🏫Real-World Example
Say you have a reusable function:
fun ButtonScope.myButtonContent() {
Text("Reusable Button")
}
You can pass this as a function reference when Compose expects a lambda with a receiver:
Button(onClick = { /* do something */ }, content = ::myButtonContent)
This makes your composable invocations concise and your reusable UI logic easy to manage.
😬Why Most Developers Miss This
Despite the clarity and conciseness these features provide, many developers don't fully leverage them because the syntax can look unfamiliar at first, and IDE support for some edge cases is still evolving.
⚡ 6. Value Classes: Boost Performance with Zero-Overhead Wrappers
Kotlin's Value Classes (formerly known as Inline Classes) are a game-changer for optimizing performance while keeping your code clean and type-safe. Despite being available since Kotlin 1.5, many developers - even experienced ones - still don't fully leverage them.
ℹ️What Are Value Classes?
Value classes allow you to create a wrapper around a single property without the usual runtime overhead of object allocation. The Kotlin compiler tries to inline these wrappers during compilation, resulting in zero runtime cost in most cases.
@JvmInline
value class UserId(val id: String)
Here, UserId
behaves like a String
at runtime, but gives you strong type safety at compile time, avoiding accidental mix-ups with other strings.
📈Why Should You Use Value Classes?
Zero runtime overhead: No extra objects created for the wrapper.
Improved type safety: Prevent bugs from passing the wrong primitive types around.
Better readability: Express your domain model clearly without clutter.
🛠️Practical Example
Suppose you have multiple ID types (UserId, ProductId) represented as strings:
@JvmInline
value class UserId(val id: String)
@JvmInline
value class ProductId(val id: String)
fun getUserName(userId: UserId): String { /*...*/ }
fun getProductName(productId: ProductId): String { /*...*/ }
The compiler prevents you from accidentally passing a ProductId
to getUserName
—which would be impossible if you used plain String
.
🧠Important Notes
Value classes require Kotlin 1.5 or higher.
They support most basic operations and can implement interfaces.
They cannot have init blocks or backing fields.
Interoperability with Java can be tricky; sometimes the wrapper becomes a real object at runtime.
🧑💼Best Practices
Use value classes to improve domain modeling in your app.
Avoid overusing them for complex data - stick to wrapping single primitive or immutable types.
Check interoperability if your project uses mixed Kotlin-Java codebases.
🧩Conclusion
Kotlin's power goes far beyond its surface-level features, and the six underrated capabilities we explored today prove exactly that. From context receivers that bring cleaner, safer APIs, to value classes that boost performance with zero overhead, these hidden gems can fundamentally change how you write, structure, and optimize your code.
Leveraging these advanced features isn't about just following trends - it's about building scalable, maintainable, and future-proof applications. The tools are here; the difference now lies in how boldly and smartly you use them.
I encourage you to experiment with these features in your real projects. Push their boundaries, combine them in creative ways, and notice how they improve your code quality and developer experience.
Got your own favorite “hidden” Kotlin feature or a clever trick? Share it in the comments below - let's build a community of Kotlin enthusiasts who raise the bar together.
Show some love with a like—it helps others discover these hidden Kotlin gems and supports me in creating more high-value content just for you.
✍️ Follow The Modular Mindset for weekly Kotlin deep-dives and engineering strategies for the modern developer.
🔔 Bookmark this space or follow The Modular Mindset on Hashnode to catch the next Kotlin deep-dive.
Subscribe to my newsletter
Read articles from Dhaval Asodariya directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Dhaval Asodariya
Dhaval Asodariya
👨💻 Software Engineer | 💡 Kotlin Expert | 📱 Android Enthusiast Previously SDE-III at DhiWise, I’m a Kotlin-focused developer passionate about building scalable, modern software-primarily on Android, but also exploring AI 🤖 and backend technologies. I use this space to share practical insights, clean code practices, and thoughts on the future of tech 🚀