Kotlin: What’s Missing and How to Work Around It
Kotlin is a modern, well-designed, general-purpose programming language that is emerging as a promising choice for web development. It offers excellent support for functional programming, full-fledged coroutines, domain-specific language creation, smart type casting, and many other advantages that are uncommon in other popular programming languages. Unfortunately, nothing is perfect, and Kotlin also has some drawbacks that may be slightly inconvenient for more advanced developers.
The aim of this blog post is not to criticize the language, but rather to spark discussion about these missing features, share practical workarounds, and help improve the language. It also serves as a short guide for developers coming from other languages, highlighting features they might miss and what they can use instead. If you're just starting to get interested in Kotlin, you should also read the post about why your company should consider switching from Java to Kotlin.
No package-private access modifier
The common approach in organizing Java code is to group it into packages that concentrate on a subset of a domain. The default access modifier is package-private, so when you don't specify an access modifier, the class/member is accessible only within the same package. Access to this package is only available via designated classes/members that were explicitly marked as public by the developer, which acts as a facade for this package. This approach aligns with one of the core concepts of object-oriented programming-encapsulation. Unfortunately, Kotlin does not have this modifier, making package encapsulation impossible at the syntax level.
Here's an example of a classic Java project structure and how it uses the package-private access modifier:
com/example/project/
├── api/
│ ├── PizzaEndpoints.java (public/package-private)
│
├── domain/
│ ├── pizza/
│ │ ├── PizzaFactory.java (public) // can be used in kitchen
│ │ ├── Pizza.java (public) // can be used in kitchen
│ │ ├── DefaultPizzaFactory.java (package-private)
│ │ ├── DoughFactory.java (package-private)
│ │
│ ├── kitchen/
│ │ ├── KitchenService.java (package-private)
│
├── infrastructure/
│ ├── DatabaseConfig.java (package-private)
Package-private was likely omitted from Kotlin to avoid a false sense of security. This modifier does not fully prevent access from other modules, as members can still be accessed if the same package name is used across modules. However, I don’t see many cases where this would be a real issue. There is always a ‘hacky’ way to bypass encapsulation, but that doesn’t mean we should. I believe package-private still brings many benefits to the table, even with this flaw. It could also be enhanced beyond Java’s equivalent by implementing measures to prevent this bypass. For more details, read about providing a package-private visibility modifier and the discussion around it.
Unfortunately, there is no easy workaround for this problem, at least not at the syntax level. Kotlin has an internal
access modifier, but it is for limiting access at the module level. Using modules just to control the visibility of specific class or function declarations would be overkill, especially when it's needed for just a few of them. This doesn't mean you should avoid splitting your project into modules - quite the opposite - it's often a good idea. However, it would be better to have more granular control over declaration visibility inside a module. If you want to enforce package-private access nevertheless, you can try using Konsist (Kotlin's alternative to ArchUnit) to write architecture-checking tests that disallow accessing non-facade members from other packages. However, this approach can be questionable and is certainly not as effective as syntax-level support.
No union types
Union types are a concept that allows you to specify a type that represents one of multiple completely independent types, with no inheritance or other relationships between them required. For example, the function below can return either Error
or String
:
fun doSomething(): String | Error = // syntax not supported yet
if(...) "string" else Error("message")
// this is exhaustive - no `else` required
val finalResult = when(val result = doSomething()) {
is String -> result.doSomething()
is Error -> handleError(result)
}
As you can imagine, this feature can be used for explicit error handling, which is a common solution in the functional programming paradigm, where exceptions are avoided because they break function purity and make the code less predictable.
Unfortunately, Kotlin does not yet support union types, but you can achieve a similar effect using sealed classes, as shown below:
sealed interface Result {
data class Success(val value: String) : Result
data class Error(val message: String) : Result
}
fun doSomething(): Result =
if(...) Result.Success("string") else Result.Error("message")
// this is exhaustive - no `else` required
val finalResult = when(val result = doSomething()) {
is String -> result.doSomething()
is Error -> handleError(result)
}
Using a sealed interface
here is neat because it ensures that no other classes can inherit from Result. This restriction can later be leveraged in when
expression to handle all possible cases exhaustively. You can read more about this approach and error handling in this Kotlin YouTrack issue.
The main problem with sealed classes is the amount of code they require. When used for error handling, a Result
-like interface and its subclasses must be reimplemented each time you want to represent a different set of possible alternating outcomes from a function. This also makes code reusability much harder. Suppose you want to add one more possible state to the Result
type (e.g., NetworkError
along with FileError
) just for one case. Then, you must effectively copy and paste the whole Result
class with all its subclasses and add another subclass representing this state. With union types, this would be much simpler:
class ParsingError
class FileError
class Success;
fun doSomething(): ParsingError | Success // syntax error: union types
fun doSomethingElse(): ParsingError | FileError | Success
Another possible workaround could be Arrow’s Either class:
fun doSomething(): Either<Success, ParsingError>
Its main downside is that it supports only two classes, which means you still have to implement your own classes for errors that are very specific and not very reusable. It may also reduce readability since it's less idiomatic. Additionally, while Arrow is a powerful library, it's also a complex beast with significant overhead, so you should probably be very careful when choosing to use it in your project. In fact, Kotlin with Arrow looks completely different from idiomatic Kotlin—it shifts toward more functional languages like Scala. If your team does not have significant experience in functional programming, I wouldn’t recommend using this library—it can make the code much harder to understand for most developers.
You can read more about the proposal and discussion on union types here. It also proposes a more complete implementation of type intersection, a feature that currently has very limited support in Kotlin.
No multi-catch block
Speaking of union types... did you know that Java actually has a limited form of union types? They are only usable as catch parameters in exception handling. Instead of writing multiple catch blocks like this:
try {
// ... throw ...
}
catch (final ClassNotFoundException ex1) {
// ... body ...
}
catch (final IllegalAccessException ex2) {
// ... body ...
}
You can write this:
try {
// ... throw ...
} catch (ClassNotFoundException | IllegalAccessException ex) {
// ...body ...
}
Sadly, Kotlin does not support union types in any form, even in the catch
block.
In Kotlin, you have a few options to work around this problem. First, you should probably avoid using exceptions altogether, if possible (in this blog post you can find more about alternative approaches for error handling in Kotlin). If this is not possible, you can consider using constructions like this:
try {
// ...
} catch (e: Exception) {
when(e) {
is ClassNotFoundException, is IllegalAccessException -> { /* ... */ }
else -> throw e
}
}
Just remember to rethrow the non-matching exceptions (else -> throw e
) to avoid silent catches. Because of this rethrowing, this approach is not ideal, but it's close enough for most cases.
You can find the discussion about introducing multi-catch blocks in this Kotlin YouTrack issue.
No complex pattern matching
Pattern matching has become a standard feature in modern programming languages. Even Java introduced it in JDK 21 by expanding the capabilities of switch expressions (read more in this blog post). Pattern matching makes code more concise and declarative, aligning well with functional programming principles. Kotlin provides a powerful when
expression for control flow and type checking but doesn’t implement full pattern-matching functionality as found in languages like Scala. This expression allows for type checking and value comparison, making it just a more concise alternative to if
-else
if
chains.
Recently, Kotlin was slightly enhanced by the introduction of experimental guard conditions in Kotlin 2.1.0, which allows adding secondary conditions to the when branches:
sealed interface Ingredient {
data class Tomato(val ripe: Boolean) : Ingredient {
fun makeSauce() {}
}
data class Cheese(val type: String) : Ingredient {
fun meltCheese() {}
}
}
fun preparePizza(ingredient: Ingredient) {
when (ingredient) {
is Ingredient.Cheese -> ingredient.meltCheese()
is Ingredient.Tomato if ingredient.ripe -> ingredient.makeSauce()
else -> println("Unknown ingredient")
}
}
In fact, this is really similar to what Java 21 offers with its guard patterns, but in Kotlin it's still in an experimental state, so you probably should not use it in production code. Going beyond Java, languages like Scala or even Python have much more developed pattern-matching capabilities than Kotlin, notably because Kotlin’s when
expression doesn’t support destructuring.
You can find the proposal and discussion about Kotlin’s pattern matching in this Kotlin YouTrack issue.
No recursive destructuring declarations
Continuing with the topic of destructuring, Kotlin has excellent support for destructuring declarations, as presented in the examples below:
data class Result(val code: Int, val message: String)
fun doSomething() = Result(5, "OK")
// destructuring works automatically for data classes
val (statusCode, message) = doSomething()
// you can omit the variable name if you don't need it:
val (_, message) = doSomething()
// can be used in loops
val map = mapOf("key" to "value")
for ((key, value) in map) {
// ...
}
// can be used in lambdas
map.mapValues { (key, value) -> "<$key>$value</$key>" }
However, Kotlin doesn’t support recursive destructuring (i.e., for nested properties). For example, this won't work:
data class Person(val name: String, val age: Int)
data class Company(val name: String, val ceo: Person)
fun findCompanies(): Map<String, Company>
findCompanies() // syntax error \/
.map { (id, (companyName, ceo)) -> "$id: $companyName (CEO: $ceo)" }
I frequently encounter situations where I miss this feature, which can be frustrating—especially since languages like Python support it, and implementing it doesn't seem particularly complex. However, there's an argument that if you find yourself wanting to use recursive destructuring inside a lambda body, it might indicate you're trying to do too much in one place, potentially making your code overly complicated.
You can learn more about the proposal to add this feature to Koltin in this Kotlin YouTrack issue. If you think this would be a valuable addition to Kotlin, consider voting for it.
Conclusion
Kotlin is a great modern general-purpose programming language that significantly improves productivity and increases developer happiness in teams 😉. Of course, nothing is ideal, and there is always room for improvement. I selected the features discussed in this post based on my experience with real-world Kotlin projects. Many developers seem to share these pain points which is reflected by the high number of votes on YouTrack. At the same time, I wouldn't be surprised if some developers prefer not to add these features, considering them unnecessary. Adding new features to a programming language is a delicate process, and language designers shouldn't implement every feature the community requests, as this would increase the language's complexity.
Maintaining simplicity is itself a valuable feature.
If you're curious, you can find more feature requests in this Kotlin YouTrack issue (I’ve applied filtering and sorting for you). If you find features you'd like to see implemented in the language, I highly encourage you to vote for them. I hope the workarounds presented here will help you deal with these limitations in your projects (or at least, you'll be able to share the pain with me). Despite these minor drawbacks, Kotlin remains an enjoyable language to work with.
Links to feature requests discussed in this post
- Provide package-private visibility modifier (or another scope reducing mechanism)
- Denotable union and intersection types
- Multi catch block
- Support pattern matching with complex patterns
- Support recursive/nested destructuring
Reviewed by: Rafał Maciak