1. Primary constructor and properties
When we first see a class in Kotlin, it seems like some kind of “special kind of function” with curly braces. And that is almost true: creating an object really does look like calling a function. The Primary constructor is that very “front door” through which we pass initial values into a new object. It is written right in the class header, immediately after the name.
In Kotlin, the primary constructor is declared in the class header: after the name in parentheses. If there are no annotations and visibility modifiers, the keyword constructor can be omitted.
Let’s look at the most straightforward variant — with an explicit constructor (it’s easier to see where it starts and ends):
class Expense constructor(title: String, amount: Int)
fun main() {
val e = Expense("Coffee", 300)
println(e) // Expense@<some_hash>
}
Yes, this code will compile and the object will be created. But there is an important detail: title and amount have not become properties yet. These are just parameters that came “as input”, and then… then they were not stored anywhere, because we did not tell Kotlin that we want to keep them.
To show the issue a bit more clearly, imagine that we try to do this:
class Expense constructor(title: String, amount: Int)
fun main() {
val e = Expense("Coffee", 300)
// println(e.title) // not allowed: title is not a property
}
The compiler is right: the object has no title property, because we did not declare it.
Constructor parameter vs. property
At this point, beginners usually have a small drama: “But I passed title into the class — why can’t I read it with dot access?” The pragmatic answer: because Kotlin distinguishes between “a value provided at creation time” and “a value stored in the object”. If you want a parameter to become part of the object, you must declare it as a property — that is, add val or var.
In Kotlin, the primary constructor can declare parameters as properties: val creates a read-only property, var creates a mutable one. Such properties are stored in the instance and are accessible from the outside via dot access.
Let’s make a proper version of Expense:
class Expense(val title: String, val amount: Int)
fun main() {
val e = Expense("Coffee", 300)
println(e.title) // Coffee
println(e.amount) // 300
}
Now title and amount are properties, they live inside the object, and we can read them.
To reinforce the difference, a small table is handy:
| Constructor notation | Is it an object property? | Can you access it as obj.x? | Where can it be used |
|---|---|---|---|
|
no | no | only inside the class body (if there is a body at all) |
|
yes (read-only) | yes | both inside the class and from the outside |
|
yes (mutable) | yes | both inside the class and from the outside |
Now an example that often occurs in real life: we want to accept “raw input”, process it (for example, trim()), and only then store it in a property. In that case, the constructor parameter does not have to be a property — it can be just an “input argument”:
class User(nameInput: String) {
val name: String = nameInput.trim()
}
fun main() {
val u = User(" Ann ")
println(u.name) // Ann
// println(u.nameInput) // not allowed: this is not a property
}
This is a useful mental model: a parameter is what came in at creation time, and a property is what we decided to keep inside the object.
2. Creating objects without new
When we create an object, we write the class name and parentheses as if we are calling a function. And that is not accidental: the constructor really is invoked as an “instance creation function”. Kotlin does not have the new keyword — an object is created simply via ClassName(...).
Let’s make an example in the style of our app (a console expense tracker). For now, we’ll just create a couple of expenses and print them:
class Expense(val title: String, val amount: Int)
fun main() {
val coffee = Expense("Coffee", 300)
val taxi = Expense("Taxi", 1200)
println("${coffee.title}: ${coffee.amount}") // Coffee: 300
println("${taxi.title}: ${taxi.amount}") // Taxi: 1200
}
Pay attention to a subtlety of the “object reference” model. Even if a variable is declared as val, it means “the reference cannot be reassigned”, not “the object is magically frozen”. It just so happens that our properties are also val right now, so the object ends up “stable”.
Here is a mini-demonstration of “two names for one object”, so your brain does not forget the model:
class Expense(val title: String, val amount: Int)
fun main() {
val a = Expense("Coffee", 300)
val b = a
println(a === b) // true
}
a === b is true, because it is the same reference. This will come in handy once var properties appear, but today our goal is specifically constructor syntax.
3. Default values
As soon as a class has more than two fields, we get a new pain: “Do I have to pass everything every time, even if some values are typically the same?” Kotlin solves this exactly the same way it does for functions: default values.
In the primary constructor, you can set default values right next to parameters/properties. If a value is not provided at creation time, the default will be used.
Let’s add a category to our Expense and make it default (as a string, without extra complexity):
class Expense(
val title: String,
val amount: Int = 0,
val category: String = "OTHER"
)
fun main() {
val e1 = Expense("Coffee")
val e2 = Expense("Taxi", 1200)
val e3 = Expense("Lunch", 900, "FOOD")
println("${e1.title}, ${e1.amount}, ${e1.category}") // Coffee, 0, OTHER
println("${e2.title}, ${e2.amount}, ${e2.category}") // Taxi, 1200, OTHER
println("${e3.title}, ${e3.amount}, ${e3.category}") // Lunch, 900, FOOD
}
The point of defaults here is not to “hide a problem”. The point is to make object creation convenient where values really are often the same, while not smearing magic numbers and strings all over the code.
One more practical detail: default values are a way to make a “minimally valid” object without extra noise. For example, if in our add command a user may omit a category, we can safely create an object with "OTHER".
4. Named arguments
When a constructor has several parameters of the same type (for example, two strings in a row), it is very easy to swap them. In a person’s head it’s “obvious”, in a computer’s head it’s “ha-ha, you passed a string into a string — totally legal”. To reduce such errors, Kotlin lets you call a constructor with named arguments — just like functions.
Named arguments are especially useful in two situations: when there are many parameters and when defaults exist. Then the call becomes self-documenting: you can read it almost like a sentence in human language.
class Expense(
val title: String,
val amount: Int = 0,
val category: String = "OTHER"
)
fun main() {
val e = Expense(
title = "Books",
amount = 1500,
category = "EDUCATION"
)
println("${e.title}: ${e.amount} (${e.category})") // Books: 1500 (EDUCATION)
}
Now it is hard to “accidentally break” the call, because the meaning of the parameters is written right in the code.
Named arguments work great with defaults: you can specify only what you need and not think about “holes” in the middle of the argument list:
class Expense(
val title: String,
val amount: Int = 0,
val category: String = "OTHER"
)
fun main() {
val e = Expense(title = "Coffee", category = "FOOD")
println("${e.title}, ${e.amount}, ${e.category}") // Coffee, 0, FOOD
}
Note: we skipped amount, and that is fine, because it has a default value.
5. A readable class header
The first two fields usually fit on one line, and everything looks nice. But life quickly brings a third parameter, then a fourth… and suddenly the class header becomes wider than the screen, and you can only read it with horizontal scrolling. So it is useful to make an agreement with your future “me” in advance: how we will format the constructor.
A good style for beginners is to put parameters on separate lines if there are more than two or if they are long. This is not about “beauty for beauty’s sake”, but about reducing cognitive load: when code is easy to scan with your eyes, it contains fewer mistakes.
class Expense(
val title: String,
val amount: Int = 0,
val category: String = "OTHER"
)
One more important point: if a parameter should not be accessible from the outside, do not make it a property “out of habit”. Sometimes you want to put val everywhere — “just in case”. But that expands the class API unnecessarily. Try to keep only truly necessary model fields in the header.
A small diagram of what happens when an object is created (very simplified, but useful to keep in mind):
flowchart TD
A["Expense(...)"] --> B["Primary constructor receives arguments"]
B --> C["val/var parameters become properties"]
C --> D["Object created, dot access: e.title, e.amount"]
This diagram covers the part we are studying today: “passed in → stored as properties → read via dot access”.
6. Migrating from Pair to the Expense class
Before classes, we often did this: one expense is a Pair<String, Int>, where first is the name and second is the amount. It worked, but it read like a “note on the fridge”: the meaning is there, but you have to remember it. Now we will replace it with Expense(title, amount, category).
I will show a very small “how it used to be” fragment (roughly, in our CLI expense tracker):
fun main() {
val expenses = mutableListOf<Pair<String, Int>>()
expenses.add("Coffee" to 300)
val (title, amount) = expenses[0]
println("$title: $amount") // Coffee: 300
}
Now “how it will be”. Let’s create a class and a list of objects:
class Expense(val title: String, val amount: Int)
fun main() {
val expenses = mutableListOf<Expense>()
expenses.add(Expense("Coffee", 300))
val e = expenses[0]
println("${e.title}: ${e.amount}") // Coffee: 300
}
From the perspective of list operations, nothing magically changed: MutableList is still there. But reading became human: e.title instead of pair.first. That is the “profit” of a domain model.
Now let’s add a default category so our add command can be simpler:
class Expense(
val title: String,
val amount: Int,
val category: String = "OTHER"
)
fun main() {
val expenses = mutableListOf<Expense>()
expenses.add(Expense("Coffee", 300, category = "FOOD"))
expenses.add(Expense("Taxi", 1200)) // category = OTHER
for (e in expenses) {
println("${e.title}: ${e.amount} (${e.category})")
// Coffee: 300 (FOOD)
// Taxi: 1200 (OTHER)
}
}
This already looks like a starting point for a real application: we can store data in a structured way, and input/commands in main will simply create objects.
7. Common mistakes when declaring classes and the primary constructor
Mistake #1: forgot val/var and expected the parameter to become a property.
A very common situation: you wrote class Expense(title: String, amount: Int) and then wondered why e.title does not exist. There is only one fix: if a field must be accessible via dot access and stored in the object, declare it as val title: String or var title: String in the header. If a field is needed only as “input”, leave it without val/var, but then explicitly store it in a property inside the class.
Mistake #2: made everything var because “it might be useful”.
This is not a syntactic mistake, but a logical time bomb. The more var, the more places where an object can change unexpectedly, and the harder it is to debug “why did the amount become 0”. At the start, it is better to choose val as the default and add var only when you can honestly answer: “Yes, by meaning this field must change after the object is created.”
Mistake #3: mixed up the order of constructor arguments.
When a constructor has several parameters, especially of the same types, positional arguments are easy to mix up. The compiler will not save you: String in place of String looks legal. So where meaning matters more than typing speed, use named arguments: Expense(title = "...", amount = ...). This noticeably reduces the number of silent errors.
Mistake #4: overused default values and “hid the meaning”.
Defaults are convenient, but you can overdo them: when a class has 8 parameters and 7 of them have default values, the object starts getting created “somehow by itself”, and it becomes unclear what actually matters. A good check: if a parameter is always important (for example, the expense amount), it probably should not have a default, so the mistake is caught immediately at creation time.
Mistake #5: a class header that is too long on one line.
Kotlin syntax allows it, but your eyes are not obliged to. If the constructor does not fit on one line without horizontal scrolling, move parameters to separate lines. This is not “cosmetics”: readability directly affects the number of bugs, especially when you are just learning and keeping a lot of new things in mind at once.