Kotlin Nitpicks: Language and Standard Library
I've written in Kotlin for the backend professionally for a little over 5 years now. I've also released a small multi-platform library (JVM/JS). The topics I'll pick on in this post are those that came up often for me. They are certainly not new things or things the Kotlin folks don't know about. I acknowledge that your experience is probably different and I encourage you to write about it.
The designers of Kotlin did a good job at balancing language features with readability and an easy learning curve. They ensured that Kotlin could become a pragmatic language for everyday use across a wide spectrum of applications. I think Kotlin is a good choice for backend development, but as with all choices, there are trade-offs.
I'll start by nitpicking the language and standard library.
Only Barely Enough Guidance
Some languages like Python and Go want to have people code in one true way. Others, like Scala, allow for such a large variety in code design, that teams need to build or adopt internal guidelines to get a somewhat coherent code base. As with many other things, Kotlin sits somewhere in the middle of this discussion.
There are detailed official coding conventions, but in practice, they aren't enough for deterministic auto-formatting (I hope to write more on this in another post). Coupled with flexible language constructs and a fairly large standard library, this means developers are often left with a couple of functionally equivalent options. This can be confusing for newcomers and has caused quite a few petty debates around what option is considered more idiomatic.
That said I do find some of the idioms a bit more problematic than others in this regard.
Null-Safety
I was using the null-safety feature of Kotlin quite a lot in the beginning, as it is such a prominent Kotlin feature. It turned out to be a golden hammer and I recommend teams should discuss and decide on how to use it properly.
Let me show what I mean by that.
Chaining Safe Calls
When working with nullables,
I can use safe calls to avoid the dreaded NullPointerException
.
I will start a chain with ?.
and then I will need to do all subsequent calls with ?.
as well.
This is because every call will yield another nullable value.
Here is an example of this in action:
person?.address?.street?.capitalize()
Can you tell from the code alone which part of the structure is nullable? Is the address optional or mandatory? Does every address have a street? Have I handled the edge cases correctly?
Now compare this with:
person ?: return null
person.address.street.capitalize()
This is called an "early return".
Due to smart casting,
the Kotlin compiler knows after the first line that the person
variable is non-null.
In the second line, the assumption is now clear:
Once we have a person
, we don't expect more surprises.
If I would now make the street
nullable,
the compiler will tell me and I will immediately see
that my assumption has been broken.
I am a big fan of locality in code. If I change some code in one place, I don't want stuff to fail in places that are much further away. At the very least, I want to know as quickly as possible when I have just broken an assumption of another piece of code.
My advice here then is to use chaining of safe calls sparingly. Make sure an important result doesn't depend on any changes in nullability of the intermediate steps. This makes it easier to avoid a class of bugs that can be quite difficult to debug.
Using Nullables for Error Handling
Some programming languages, like Java, require developers to declare which exceptions might be thrown by a function. In contrast, Kotlin does not have checked exceptions. While this makes the code look more concise, it hides the fact that a function might throw, which is actually an important part of a function's behavior.
Because of this, I am always trying to avoid throwing exceptions for anything that is recoverable. Instead, I will typically return errors just like the normal value from the method.
As an example, consider this function signature:
fun Hotel.makeReservation(stay: Stay, cat: Category?): Reservation?
What does it mean when the Reservation is null
?
No room available on the given date? Did the hotel close for a renovation?
No room available for the requested category?
Also, what is the meaning of the nullable Category
?
Does null
mean any category is fine
or is it just for convenience
so I don't have to provide the category
in case the Hotel only has a single category?
Could this also throw an exception? 🤷♂️
The issue with nullable types here is that they can only be either null or some value, but the intent of a null value is unclear. This means there is now an implicit contract between the caller and the callee and that in turn can lead to some very hard-to-spot bugs when the expectations don't line up or when they change in the future.
Compare the example above to this snippet:
sealed class CategoryFilter {
object All : CategoryFilter()
data class Only(category: Category) : CategoryFilter()
}
sealed class ReservationResponse {
data class Reservation(reservationId: Id) : ReservationResponse()
object NoRoomForRequestedCategory : ReservationResponse()
object HotelFullyBooked : ReservationResponse()
object HotelClosed : ReservationResponse()
}
fun Hotel.makeReservation(
stay: Stay,
filter: CategoryFilter = CategoryFilter.All
): ReservationResponse
Granted, this is much more verbose than the previous example,
but now it should be very obvious what this method is expecting
and how the return value should be interpreted.
I would still go ahead and implement
real exceptions (like IO exceptions) as RuntimeExceptions
,
but there are also functional alternatives to error handling.
The reason why I see this as an issue with Kotlin in particular,
more than with some other programming languages,
is that working with nullable types
is such a prominent feature of the language.
Kotlin has a lot of syntactic sugar to support them,
like being able to add a ?
to any existing type to make it nullable,
the "safe" operators ?.
and as?
, the Elvis operator ?:
and convenience functions on collections
like mapNotNull
and filterNotNull
.
It makes sense that as a Kotlin dev
you want to use what the language provides you with.
My advice here is, again, to be aware of the trade-off when using nullable types and second guess the use of too many nullables in your Kotlin codebase. Don't make nullable types your golden hammer.
I've complained enough about nullable types. Let's move on to something else.
Missing Union Types
I don't know if you noticed, but the example above for the hotel reservation has a major downside in that all the subtypes of a sealed class need to be in the same file as the sealed class, and they need to inherit the sealed class as well. This rules out returning a primitive, or a library-provided type, without wrapping it into another class.
This becomes problematic in case you want to return a Reservation
with different error cases from a different method.
In practice, this means
that you might also need to wrap the Reservation
into a ReservationSuccess
class
that would inherit ReservationResponse
..., so much about being concise.
The reason for some of this wrapping and resulting verbosity is Kotlin's lack of union types. With union types we could rewrite the example above like this:
object AllCategories
data class SelectedCategory(category: Category)
data class Reservation(reservationId: Id)
object NoRoomForRequestedCategory
object HotelFullyBooked
object HotelClosed
// `union` does not exist in Kotlin
union CategoryFilter = AllCategories | SelectedCategory
union ReservationResponse =
Reservation |
NoRoomForRequestedCategory |
HotelFullyBooked |
HotelClosed
fun Hotel.makeReservation(
stay: Stay,
filter: CategoryFilter = AllCategories
): ReservationResponse
One of the main reasons why Kotlin is not supporting union types
is that their stewards want to be 100% interoperable with Java.
In Java, in most cases,
a union type would simply be erased and replaced with Object
.
Not a good situation to be in.
On the other hand, Kotlin wants to be a multiplatform language
and one of the other targets is JavaScript.
In JavaScript, a lot of global functions
and libraries accept "union types" as arguments.
While Kotlin has the type dynamic
to work around this in Kotlin/JS,
it is a hack and gets rid of all type checking in those places.
In comparison, TypeScript supports union types
and therefore can also type-check code calling functions
that except e.g. string | number
.
A possible workaround is to use a library like
Arrow
or build yourself an Either<A, B>
type,
where A
and B
are two possible types that could be returned.
You could then nest these types to build more complex return values.
However, without native language support,
this becomes quite ugly,
especially when you need to take it apart using nested when
expressions.
In addition, union types are typically commutative,
meaning that A | B
is identical to B | A
,
which means they can be composed more easily
and are more robust to simple refactorings in the order of the types.
An Either<A, B>
will always be different from Either<B, A>
.
Since Kotlin 1.5 there is also a built-in Result<T>
type. The description reads:
A discriminated union that encapsulates a successful outcome with a value of type T or a failure with an arbitrary Throwable exception.
If you are looking at this type to model domain-specific error conditions,
then I recommend forgetting about this type immediately.
The KEEP says as much.
The main reason is that
it only allows instances of type Throwable
for the failure case.
Say goodbye to exhaustive when
expressions.
I believe missing union types makes well-typed Kotlin code more verbose compared to other languages like TypeScript or F#. Especially when doing Domain Driven Design where you want to work with explicit types wherever possible. I can't offer good advice here, other than that I recommend not to take this as an excuse to design less with types.
Efficient Collections in the Standard Library
The Kotlin standard library has a lot of very useful functions on collections,
like map
and filter
.
What might not be immediately clear,
is that when invoked,
these methods will actually do a full copy of the existing collection
to keep the existing one intact.
Compared to Clojure or Scala,
the Kotlin builtin collections are actually just very thin wrappers
around the underlying Java collections
(or other collections depending on platform).
Copying an entire collection by accident can happen very quickly in Kotlin
as opposed to e.g. Java.
This is because in Java,
you need to first turn the collection into a Stream
before you get access to map
, filter
, etc.
and you need to explicitly convert it back to a list with a collector.
In Scala and Clojure collections are sharing their structure on modification by default. So when you prepend an element to a list, it will not create a completely new list, because the tail of the new list is the existing list.
In Kotlin, we can get the same result as in Java by turning a collection into a stream, then applying the transformations and finally turning the stream into a list again:
veryLargeList
.stream()
.map(::someOperation)
.filter(::somePredicate)
.toList()
The more idiomatic way is to
turn the collection into a Sequence
using asSequence
.
My advice is to look under the hood of the Kotlin standard library
by jumping to the definition of the various helper methods,
especially regarding collections.
If you are chaining multiple transformations on collections
that might be really large,
you should consider switching to Stream
or Sequence
instead.
There is some ongoing work to bring immutable collections to Kotlin cross-platform, but it's unclear if this will become part of the standard library.
Conclusion
Kotlin is heavily marketed as being concise, safe and interoperable — and I do principally agree with that. The code I can write in Kotlin is denser than in other languages I am used to, making it easier for me to focus on the important bits of code.
Some time ago, I got this 👆 reply on Twitter and it got me thinking: Is Kotlin really just hype and only a lot of fancy syntactic sugar on top of the JVM? I don't think so, mainly because even with the flaws it currently has, I still believe Kotlin is a really good language with an awesome standard library. Overall it makes for an efficient and enjoyable experience on the JVM, even with Java slowly catching up. But as with all tools in our toolbelt we should know how to wield them safely.