1. Interface as a contract
When you first learn about inheritance, a pleasant feeling appears: “Oh, I can make a base class and inherit everything from it!”. That feeling usually lasts exactly until the first project, where it turns out that “similarity” can be very different. A book and a user are not similar to each other (different data), but sometimes both must “be able to have an id”. A payment and an expense are not relatives, but both must “be able to be shown as a line in a report”.
This is where interfaces start to shine: an interface describes not “what it is”, but “what it can do”.
If we simplify to a human analogy: inheritance is “being a kind of” (a cat is a kind of animal), while an interface is “being able to do” (a cat can meow(); a robot can also “be able to meow” — if you decide so… and nobody has the right to stop you).
An interface in Kotlin is a contract. A contract means: “If you say that you are X, then you must provide these functions and/or properties”. At the same time, an interface says almost nothing about how exactly you will do it.
2. Declaring an interface and implementing it in a class
Declare a contract and “sign up” for it
Now comes a moment where Kotlin looks friendly: interfaces are written more simply than it seems. You declare interface, and inside you list what the implementation must be able to do: functions and properties. Then any class can “sign the contract” by adding : InterfaceName after the class name.
Let’s start with a minimal example — the contract “an object has an identifier”.
interface Identifiable {
val id: String
}
class User(override val id: String, val name: String) : Identifiable
It’s important to notice a few things here.
First, val id: String inside an interface is not a “field”, not a “memory cell”, not a “box for a string”. It is exactly a requirement: “you must have access to id”.
Second, the User class fulfills the requirement via override val id: String right in the primary constructor. This is a very convenient and readable style: you immediately see which fields are part of the contract.
Third, there are no parentheses () for an interface: you can’t instantiate an interface as an object directly. It is only a description of the rules of the game.
Abstract methods: “if you implement it, you must write it”
An interface often starts with the thought “the type has certain responsibilities”. The most direct way to express a responsibility is to declare a function without a body. Then it becomes abstract: an implementation must write the code.
For example, let’s make the contract “can greet”.
interface Greeter {
fun greet(name: String): String
}
class FriendlyGreeter : Greeter {
override fun greet(name: String): String = "Hi, $name!"
}
If you forget override, the compiler won’t stay silent. And this is one of Kotlin’s best traits: it doesn’t let you “accidentally almost implement an interface”. Here, either the contract is fulfilled, or it isn’t.
To quickly feel how this is used, let’s do a mini-check in main:
fun main() {
val g: Greeter = FriendlyGreeter()
println(g.greet("Kotlin")) // Hi, Kotlin!
}
Notice: the variable g has type Greeter, not FriendlyGreeter. This isn’t pedantry — it’s the whole point of contracts. We work through the promise “can greet”, not through a concrete class.
3. Properties in an interface and implementation approaches
Properties in an interface are one of the places where beginners most often stumble, because intuitively it seems: “Well, if val id is there, then somewhere a string is stored”. But no.
An interface can require that a property exists, but it cannot force you to store it in a particular way. An implementation can store the value in a field, or it can compute it on the fly.
Let’s look at three ways to implement the same requirement.
Stored in a field (the most common option)
interface Identifiable {
val id: String
}
class Expense(override val id: String, val title: String) : Identifiable
Expense actually stores id as a property (it will have a backing field).
Computed property (no storage)
interface Identifiable {
val id: String
}
class Session(private val token: String) : Identifiable {
override val id: String
get() = "session:$token"
}
Here, id is computed each time via the getter. There may be no separate field for id at all.
var in an interface: a read/write contract
An interface can require a var, meaning both reading and writing. But do this carefully: you force implementations to be mutable, and that isn’t always desirable.
interface Renameable {
var title: String
}
class Note(override var title: String) : Renameable
Here the class must provide both a getter and a setter. And yes, again: the interface doesn’t store — the implementation does.
To make the difference clear, here’s a small table:
| What’s written in the interface | What it means | Where the value lives |
|---|---|---|
|
“you can read x” | in the implementation (or computed) |
|
“you can read and change x” | in the implementation |
|
“the function is required” | code in the implementation |
|
“there is default behavior” | code in the interface |
4. Default implementations: basic behavior right in the contract
Now we get to the magic that Kotlin does quite cleanly: in an interface you can write functions with a body. Such functions become default implementations: a class may choose not to override them and simply inherit the behavior.
In Kotlin’s documentation it looks exactly like this: an interface can contain a method implementation (an example with interface Polygon { fun draw() { ... } }).
Imagine that in our educational console app (an expense tracker) we want to print entities “nicely”, and we need a common contract “can represent itself as a string”. Let’s create an interface:
interface RenderableLine {
fun renderLine(): String
}
But for now this is just an abstract obligation. Let’s make it more interesting: add properties to the contract, and give renderLine() a default implementation.
interface TitledAmount {
val title: String
val amount: Double
fun renderLine(): String = "$title: $amount"
}
Here renderLine() uses only what the contract guarantees: title and amount. This is the key rule of a good default implementation: it must not require “hidden” things that the interface doesn’t mention.
Now a class can “just sign up”:
class Expense(
override val title: String,
override val amount: Double,
) : TitledAmount
And that’s it — Expense can already do renderLine().
Let’s test it:
fun main() {
val e = Expense(title = "Coffee", amount = 3.5)
println(e.renderLine()) // Coffee: 3.5
}
It’s important to understand a nuance: a default method is not a “thing for lazy people”. It’s a way to move common behavior closer to the contract, so that all implementations behave the same “by default”, and differences are explicit.
5. Interface as a type: reusable functions
An interface becomes truly useful when you start accepting it as a function parameter and returning it as a result. Then the code stops being tied to specific classes.
For example, let’s write a function that prints a list of any objects that can render themselves as a line.
fun printLines(items: List<RenderableLine>) {
for (item in items) {
println(item.renderLine())
}
}
Now let’s make our expense model conform to this contract. You can do it like this (with an explicit method implementation):
data class Expense(
val id: String,
val title: String,
val amount: Double,
) : RenderableLine {
override fun renderLine(): String = "[$id] $title — $amount"
}
Let’s verify that everything works:
fun main() {
val items = listOf(
Expense("e1", "Coffee", 3.5),
Expense("e2", "Transit", 2.75),
)
printLines(items)
// [e1] Coffee — 3.5
// [e2] Transit — 2.75
}
Notice Kotlin’s “pleasant audacity”: List<Expense> can be passed where List<RenderableLine> is expected, because Expense : RenderableLine, and List in Kotlin is covariant (you already touched this in the topic about variance). But today what matters to us is not the word “covariance”, but the effect: the function became reusable, and the code became less sticky.
Mini-step in an app: a common contract for console output
Right now we have a console app where we add and print expenses. A typical beginner version of the list command looks roughly like this: inside the command you manually format a line, then you format a line again in a report, then again in an export… and suddenly you discover you have three different formats for “the same expense”. The program turns into a series called “Formatting: Revenge of the String”.
To avoid that, let’s start with a small, honest contract: “everything that can be shown to the user can represent itself as a string”.
Let’s make an interface:
interface CliPrintable {
fun toCliLine(): String
}
Now Expense implements it:
data class Expense(
val id: String,
val title: String,
val amount: Double,
) : CliPrintable {
override fun toCliLine(): String = "[$id] $title: $amount"
}
And in main (or in the list command function) we print not “whatever”, but consistently:
fun printAll(items: List<CliPrintable>) {
for (item in items) {
println(item.toCliLine())
}
}
Example usage:
fun main() {
val expenses: List<CliPrintable> = listOf(
Expense("e1", "Coffee", 3.5),
Expense("e2", "Transit", 2.75),
)
printAll(expenses)
// [e1] Coffee: 3.5
// [e2] Transit: 2.75
}
Here we did an important thing: separated “what to print” from “how to print”. Yes, the word “architecture” sounds big, but in practice it’s just discipline: one format lives in one place, and everyone uses it.
Another neat step is to add a default implementation where we already have properties from which the string is built. For example, for objects “with a title”:
interface HasTitle {
val title: String
fun titleLine(): String = "• $title"
}
A class can simply implement val title, and get titleLine() for free. This is exactly how default methods should feel: like “reasonable standard behavior”, not like a “crutch”.
6. Typical mistakes when working with interfaces
Mistake #1: thinking interface properties are storage fields.
A common confusion: a student sees val id: String in an interface and expects the interface to “hold a string” somewhere. In Kotlin, an interface defines only access (getter/setter), while storing or computing is the implementation’s job. If you remember “interface = requirements” and “class = specifics”, your head starts hurting noticeably less.
Mistake #2: forgetting override and trying to “implement by intuition”.
Sometimes people write val id: String in a class but don’t mark it with override, and then they are surprised by compilation errors or by the fact that “the wrong thing” got implemented. In Kotlin, override is not a formality; it’s an intent marker: “I am fulfilling the contract”. It’s better to get used to adding it right away and treat it like a seat belt.
Mistake #3: making an interface too broad (“a combine harvester with 18 functions”).
It’s very tempting to create an interface EverythingManager that includes printing, validation, saving, and “also let it brew coffee”. Then it turns out that half of the implementations are forced to write empty methods or throw exceptions. A good interface is usually narrow and focused: one contract — one responsibility.
Mistake #4: writing default methods that depend on things that aren’t in the contract.
A default implementation must rely only on what the interface guarantees: its properties and methods. If a default method “expects” that the implementation stores some hidden field or performs a side effect, you get fragile behavior and surprises. The best test: “Can I implement this interface in a new class without reading someone else’s mind?”
Mistake #5: confusing “interface as a contract” with “inheritance as a hierarchy”.
An interface doesn’t have to reflect a family tree of objects. It reflects capabilities. That’s its power: the same class can be an “expense”, but also “something that can be printed” and “something that has an id”. This is normal and even desirable, because the code becomes more modular.
7. Review questions
- Why is an interface called a contract, and what exactly does a class “promise” when it writes : SomeInterface?
- What is the difference between an interface method without a body and an interface method with a body, and why do we need a default implementation at all?
- Why is val in an interface not a “field”, and what are the two main ways to implement such a property in a class?
- What is the practical point of writing functions that accept an interface (CliPrintable) rather than a concrete class (Expense)?
- What sign indicates that your interface has become too “fat”, and what usually happens to implementations of such an interface?