1. Primary constructor: the “front door” into a class
When we start writing classes, it’s tempting to think: “Well, a constructor is just a way to stuff values inside.” But in reality, a constructor is the public entry point into your type, i.e., part of the class API. It’s like the front door to an office: you can make it convenient and clearly labeled, or you can install 12 identical doors with no signs—and then wonder why employees keep walking into the server room.
In Kotlin, the main way to create an object is the primary constructor, which is written right in the class header. It’s considered the “main creation path,” and the rest of the code is usually built around it. The Kotlin documentation describes this directly: the primary constructor is declared in the header, and parameters can become properties via val/var.
A simple example of “a class as an object-creation function”:
class User(val name: String, var age: Int)
fun main() {
val u = User(name = "Ann", age = 20)
println(u.name) // Ann
u.age += 1
println(u.age) // 21
}
Notice another “nice Kotlin bonus”: when creating an object, you don’t need new—you call the class like a function.
It’s a small thing, but it makes code more compact: fewer “ritual words,” more meaning.
2. Constructor parameter vs property: what becomes state
This is where almost all beginners step on the same rake. It seems logical: “I wrote class X(a: Int)—so the object has a.” But no: a constructor parameter without val/var is just an input value, like a function parameter. It doesn’t have to become part of the object’s state.
Kotlin can do a neat thing: if a parameter is declared with val or var, it is both a constructor parameter and an object property at the same time. This is one of the key elements of “Kotlin style”—less boilerplate.
Let’s compare two variants.
Variant A: the parameter is a property
class Label(val text: String)
fun main() {
val l = Label("Hi")
println(l.text) // Hi
}
Variant B: the parameter is not a property
class Label(text: String) {
val text: String = text
}
fun main() {
val l = Label("Hi")
println(l.text) // Hi
}
Both variants work, but their meaning is different. In variant B, you’re basically saying: “When creating me, you gave me text, but what I’ll store is text (my own).” The Kotlin documentation separately emphasizes that parameters without val/var are not stored as properties and are only available inside the class body until you copy them into a property.
So you don’t have to keep this in your head “by feel,” it helps to pin it down like this:
| Header notation | Is this an object property? | Can you access it from outside (obj.x)? | Typical meaning |
|---|---|---|---|
|
no | no | an input value “at creation time” |
|
yes (read-only) | yes | part of the state; cannot be changed |
|
yes (mutable) | yes | part of the state; can be changed |
3. Designing Expense: a type that’s pleasant to create
Here’s an important practical point: a good constructor is one that’s pleasant to look at at the call site. Because the real pain isn’t writing the class—it’s opening the code a month later and figuring out what Expense(1, "Taxi", 900, "transport") means—is it a taxi ride for 9 USDT or for 900 USDT, and what is that “1” anyway?
Let’s imagine that in our console expense tracker, one record is an expense: it has an identifier, a title, an amount (in whole units, so we don’t suffer with Double), and a category as a string (a string for now: enum will come later).
Let’s start simple:
class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String
)
fun main() {
val e = Expense(1, "Coffee", 350, "food")
println(e.title) // Coffee
println(e.amount) // 350
}
Already not bad: it’s one object, everything is together, and we no longer glue data “by index” across different lists.
But honestly: the line Expense(1, "Coffee", 350, "food") is only moderately readable. With four parameters it’s still tolerable, but as soon as a fifth appears, you’ll start “guessing.”
This is where the constructor as an API shows up especially clearly: we’re not just choosing types, we’re also designing the readability of the call.
4. Default values: less chaos during creation
At some point you’ll want: “Let the category be optional—if it’s not specified, treat it as "other".” And there are two paths. One path is to build several creation variants (spoiler: secondary constructors will come later). The second path is default values.
The primary constructor supports default values just like functions do. This is officially described: you can set defaults right in the constructor parameters, and if an argument isn’t provided, the default value will be used.
Let’s do it like this:
class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String = "other"
)
fun main() {
val a = Expense(1, "Coffee", 350)
val b = Expense(2, "Taxi", 900, "transport")
println(a.category) // other
println(b.category) // transport
}
An important effect: now our class has fewer “creation modes”, and that’s good. The reader’s brain shouldn’t have to remember that “if the category didn’t come in, you have to create the object differently.” The class itself “knows” how to start correctly.
In practice, defaults most often give you two wins. First, they reduce the amount of required data: you keep required only what the object makes no sense without. Second, they reduce the number of creation-time mistakes: fewer places where you can “forget something.”
5. Named arguments: readability at the call site
Now we’ll do what turns “four parameters—tolerable” into “six parameters—still readable”: named arguments.
The key idea is simple: constructor parameter names aren’t just “whatever you called them internally.” They’re part of the external interface: people will see them in calls, especially if they use named arguments. So a bad parameter name in a constructor is like a bad label on a button in an app.
Look how much clearer this is:
class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String = "other"
)
fun main() {
val e = Expense(
id = 10,
title = "Lunch",
amount = 550,
category = "food"
)
println(e.title) // Lunch
println(e.amount) // 550
}
Yes, it’s a bit longer. But “length” here works as insurance against mistakes—especially when parameters of the same type repeat. Two String lines in a row are a classic source of “I swapped them and didn’t notice.”
To see how easy it is to make a mistake without named arguments, imagine a class:
class Client(val firstName: String, val lastName: String)
fun main() {
val c = Client("Ivan", "Petrov")
println("${c.firstName} ${c.lastName}") // Ivan Petrov
}
If you accidentally swap them, the compiler won’t save you: the types are the same. But with named arguments, the chance of mixing them up is much lower.
6. val or var: “can be changed” is part of the API
Here comes the moment where a developer grows up and starts saying scary things like “mutability must be earned.” But without fanaticism: var isn’t evil—it just opens the door to changes, and changes need to be controlled.
The difference is simple. val in the constructor means the object is created and that property can’t be reassigned (even though if it holds a mutable object, that’s a separate story—we’re not going there now). var means you can change the property value after creation.
For example, for an expense it usually makes no sense to change id after creation, and often it makes no sense to change amount (an expense record is a fact). So in the model it’s logical to keep those as val. But if we want to allow editing a record (for example, to fix a typo in the title), we can make title a var. The important thing is that this is a conscious decision, not “I’ll put var everywhere so I don’t have to think.”
An example of “conscious mutability”:
class Expense(
val id: Int,
var title: String,
val amount: Int,
val category: String = "other"
)
fun main() {
val e = Expense(id = 1, title = "Cofee", amount = 350) // typo :)
e.title = "Coffee"
println(e.title) // Coffee
}
Here var title reads like the class’s promise: “The title can be corrected.” And val amount reads like a promise: “We don’t touch the amount; it’s a fact of the expense.” And that’s exactly what “the constructor is part of the API” means: through val/var you communicate the rules for using your type.
7. When a parameter should not be a property
Sometimes you want to pass something temporary into the constructor: for example, “raw text” that’s only needed to compute a normalized value. Or, say, you accept the string " Food " and want to store "food".
Today we’re not doing init yet (that’s the next lecture of the day), but it’s important to understand the idea: sometimes a constructor parameter is just material you use to build the state, and you don’t need to store the material itself.
Here’s a micro-example on an easy topic:
class Category(raw: String) {
val name: String = raw.trim().lowercase()
}
fun main() {
val c = Category(" FoOd ")
println(c.name) // food
}
Why isn’t raw a val raw? Because we don’t need to store “dirty input.” We want to store an already prepared value that’s suitable for logic.
This approach fits well with the idea from the documentation: a parameter without val/var is not a property and doesn’t “live inside the object” by itself.
8. Practice and useful nuances
When you need constructor, and when you don’t
Sometimes you’ll see two variants:
class Person constructor(name: String)
and
class Person(name: String)
The difference is not in meaning, but in syntax. Kotlin allows you to omit the constructor keyword if there are no annotations or visibility modifiers on the constructor. This is normal practice: most classes are written without constructor.
In our course, in 99% of cases we’ll write it compactly:
class Expense(val id: Int, val title: String, val amount: Int)
And the word constructor will come in handy later, when more “grown-up” cases start (for example, constructor visibility modifiers), but that’s not the topic of this lecture.
Diagram: primary constructor as a contract
It’s useful to mentally picture object creation as a mini data flow: the outside world passes values → the class accepts them by contract → the object appears in the program.
Here’s a simple diagram:
flowchart LR
A["Outside code
Expense(...)"] --> B["Primary constructor
(parameters)"]
B --> C["Object properties
(val/var)"]
C --> D["Object is ready to use"]
In this picture, the primary constructor is where you define the contract: what data is required, what is optional (defaults), what becomes part of the state (val/var), and what is just input.
Mini step: an expense list of objects
So we don’t leave everything in “API theory,” let’s take a micro-step in our app: in main() we’ll create a few expenses and print them. No complex commands—just feel that the model became readable.
class Expense(
val id: Int,
val title: String,
val amount: Int,
val category: String = "other"
)
fun main() {
val expenses = mutableListOf<Expense>()
expenses.add(Expense(id = 1, title = "Coffee", amount = 350, category = "food"))
expenses.add(Expense(id = 2, title = "Taxi", amount = 900, category = "transport"))
expenses.add(Expense(id = 3, title = "Notebook", amount = 120)) // default category
for (e in expenses) {
println("#${e.id}: ${e.title} — ${e.amount} (${e.category})")
// #1: Coffee — 350 (food)
// #2: Taxi — 900 (transport)
// #3: Notebook — 120 (other)
}
}
Here you can immediately see that the primary constructor really “works like an API”: it dictates what fields an expense has, what they’re called, and which values you can omit. And named arguments turn creation into almost self-documenting code.
9. Common mistakes
Mistake #1: confusing a constructor parameter with a property and forgetting val/var.
It looks like this: you write class Expense(id: Int, title: String), then you try to access e.title, and the compiler says “no such member.” In fact, everything is fair: title was only an input parameter, not part of the state. If a value should live inside the object, make it val title: String right in the constructor, or assign it to a property inside the class body.
Mistake #2: making all properties var, because “it might come in handy.”
This is one of the most expensive habits. When you set var, it’s like leaving a door open: “this can be changed at any moment.” Then it turns out someone changed it somewhere, logic went off the rails, and you hunt for the culprit like a detective with a magnifying glass. Start with val and switch to var only for what truly must change by meaning.
Mistake #3: too many parameters in the primary constructor and “magic” argument positions.
If a constructor requires 6–8 parameters and you call it positionally (for example, A(1, "x", "y", 10, 20, true)), the code starts looking like a spell you must not touch or you’ll summon a demon. At this lecture level, the simplest rescue is to use named arguments and default values more actively so only truly necessary things remain required.
Mistake #4: bad parameter names in the constructor.
The constructor is part of the public interface. If you name parameters a, b, x1, s2, you literally force the reader to guess what to pass. It’s especially painful when you use named arguments: Expense(a = 1, b = "Coffee", x1 = 350). Parameter names should reflect meaning, because they will live in object-creation code.
Mistake #5: not using default values where they are natural, and multiplying complexity because of it.
When a parameter is often the same (for example, category "other" or a flag isActive = true), it’s more convenient to give it a default value in the primary constructor than to force every call to repeat the same thing. Kotlin supports this directly: defaults in the primary constructor work the same way as defaults in functions.