Variables #
Variables are where you store data while a program runs. In other languages like Java or JavaScript, every variable can basically have its value changed — it’s up to you to stay disciplined about which ones may change and which may not. Kotlin changes this approach: it forces you to decide from the start whether a variable may change or not, through two different keywords: val and var. This choice isn’t just style — it has a real impact on code safety and readability. This article covers val and var in depth, along with related variable features like type inference, scope, null safety, lateinit, by lazy, and destructuring.
val — Variables That Can’t Be Reassigned
#
val is short for value. Once initialized, its reference can’t be replaced with another value. This is what’s most often called an “immutable variable”, although the more accurate term is read-only reference — because the object a val points to could still be modifiable internally.
val name = "Budi"
val birthYear = 1995
val active = true
// name = "Sari" // ✗ error: Val cannot be reassigned
Understanding the difference between a read-only reference and an immutable object is important:
// val only locks the REFERENCE, not the object's contents
val list = mutableListOf("apple", "mango")
// list = mutableListOf("orange") // ✗ can't — the reference is locked
list.add("orange") // ✓ possible — the object's contents are modified
list.remove("apple") // ✓ possible
println(list) // [mango, orange]
If you want the object itself to be truly unmodifiable, use an immutable collection:
// CORRECT: both the reference and the contents are protected
val fixedList = listOf("apple", "mango", "orange")
// fixedList.add("grape") // ✗ compilation error — List has no add() method
val Doesn’t Have to Be Initialized Immediately
#
A val can be declared without an initial value, as long as it’s definitely initialized before use — and initialized only once. The Kotlin compiler is smart enough to track this.
val message: String
val condition = true
if (condition) {
message = "Condition met"
} else {
message = "Condition not met"
}
println(message) // safe — the compiler is sure message is initialized
// ANTI-PATTERN: a val that's initialized more than once
val score: Int
score = 80
// score = 90 // ✗ error — already initialized before
var — Variables That Can Be Reassigned
#
var is short for variable. Its value can be changed at any time while the program runs.
var score = 0
println(score) // 0
score = 100
println(score) // 100
score += 50
println(score) // 150
score--
println(score) // 149
var fits data that genuinely changes over time: game scores, connection status, counters, accumulated values in loops, and the like.
var connectionStatus = "Disconnected"
var attempts = 0
while (attempts < 3) {
val success = tryConnect()
if (success) {
connectionStatus = "Connected"
break
}
attempts++
connectionStatus = "Retrying... ($attempts/3)"
}
println(connectionStatus)
Choosing Between val and var
#
This is a decision you make dozens of times a day. Here’s the simple guideline:
USE val if:
✓ The value doesn't need to change after it's set
✓ It's computed once and used many times
✓ You're unsure — try val first, switch to var if needed
USE var if:
✓ The value will genuinely change during execution (counters, accumulators)
✓ The variable is initialized under certain conditions (if/else/try)
✓ State that must be updated from outside (e.g., a class property that can be set)
// ANTI-PATTERN: using var when the value never changes
var baseUrl = "https://api.example.com"
var apiVersion = "v2"
var timeout = 30_000
// CORRECT: use val because the values are constant at runtime
val baseUrl = "https://api.example.com"
val apiVersion = "v2"
val timeout = 30_000
Using val as the default isn’t just style — it makes code easier to reason about. When you read code and see val, you know the reference won’t change under you. When you see var, you know you need to track where its value can change.
Type Inference — Automatic Types from Values #
Kotlin can guess a variable’s type from the given value. You don’t always need to write the type explicitly.
val name = "Rina" // → String
val age = 27 // → Int
val height = 165.5 // → Double
val active = true // → Boolean
val character = 'K' // → Char
val population = 8_000_000L // → Long (L suffix)
val temperature = 36.5f // → Float (f suffix)
When to Write the Type Explicitly #
Type inference doesn’t mean types aren’t important — it only reduces redundancy. There are situations where writing the type explicitly is better:
// Explicit needed: the compiler infers Double, but you want Float
val ratio: Float = 0.75
// Explicit needed: a variable declared without an initial value
var result: Int
result = calculateSomething()
// Explicit needed: a more general type than the concrete value
val list: List<String> = mutableListOf("a", "b")
// → the type appears as List, not MutableList — a narrower interface is exposed
// Explicit needed: clarity for the reader
val dbConnection: DatabaseConnection = ConfigDb.create()
// clearer than making the reader guess ConfigDb.create()'s return type
// ANTI-PATTERN: explicit types that are already obvious from the value
val name: String = "Budi"
val age: Int = 25
val active: Boolean = true
// CORRECT: let the compiler guess
val name = "Budi"
val age = 25
val active = true
Variable Scope #
Scope determines where a variable can be accessed. In Kotlin, variables live in the block { } where they’re declared.
fun scopeExample() {
val outsideBlock = "accessible anywhere in this function"
if (true) {
val insideIf = "only exists inside this if block"
println(outsideBlock) // ✓ possible
println(insideIf) // ✓ possible
}
// println(insideIf) // ✗ error — insideIf is out of scope
println(outsideBlock) // ✓ still possible
}
Shadowing #
Kotlin allows a variable in an inner scope to hide (shadow) a variable with the same name in an outer scope:
val message = "outer message"
run {
val message = "inner message" // ✓ valid — shadows the outer variable
println(message) // inner message
}
println(message) // outer message — unaffected
Shadowing makes code hard to read and prone to bugs — which variable is being modified? Avoid giving the same name to variables in different scopes within the same function, except in cases like lambda parameters where short conventional names such as it are standard.Top-Level Variables #
Kotlin allows variables to be declared outside classes and functions — directly at file level (top-level). These variables can be accessed from anywhere within the same package.
// File: Constants.kt
package com.myapp.config
var accessCount = 0 // top-level var — accessible from the whole package
val APP_NAME = "MyApp" // better to use const val for constants — see the Constants article
Nullable Variables #
By default, all Kotlin variables are non-nullable — they can’t be assigned null. To allow null, add ? to the type.
// Non-nullable: can't be null
var name: String = "Budi"
// name = null // ✗ compilation error
// Nullable: can be null
var address: String? = null
address = "Jl. Merdeka No. 1"
address = null // ✓ allowed
Accessing Nullable Variables Safely #
var email: String? = getUserEmail()
// ANTI-PATTERN: direct access without a check
val length = email.length // ✗ compilation error — email can be null
// CORRECT: safe call
val length = email?.length // Int? — can be null if email is null
// CORRECT: with a default value via Elvis
val length = email?.length ?: 0 // Int — 0 if email is null
// CORRECT: explicit check
if (email != null) {
println(email.length) // inside this block, the compiler knows email isn't null
}
// CORRECT: let for executing a block only when not null
email?.let { e ->
println("Send to: $e")
sendEmail(e)
}
Smart Cast #
After you explicitly check for null, Kotlin automatically casts the variable to its non-nullable type within that block — without needing a manual cast.
var text: String? = getText()
if (text != null) {
// Here text is automatically treated as String (non-nullable)
println(text.uppercase()) // ✓ no need for text?.uppercase()
println(text.length) // ✓ safe
}
// Smart cast also works with when
when {
text == null -> println("Text is empty")
text.isBlank() -> println("Text is only whitespace")
else -> println("Text: $text") // here text is definitely non-null and non-blank
}
lateinit — Deferred Initialization
#
lateinit is used for var properties that are guaranteed to be initialized before use, but can’t be initialized at declaration time. It’s most often used in frameworks like Spring or Android, where dependency injection or the lifecycle framework initializes the value.
class UserViewModel {
lateinit var repository: UserRepository
lateinit var name: String
fun initialize(repo: UserRepository) {
repository = repo
name = "Default"
}
fun display() {
println("User: $name")
repository.fetchAll()
}
}
lateinit Rules
#
lateinit can only be used under certain conditions:
lateinit ONLY WORKS for:
✓ var (not val)
✓ Non-nullable types
✓ Reference types (String, custom classes, etc.)
lateinit CANNOT be used for:
✗ val
✗ Nullable types (String?, Int?)
✗ Primitive types (Int, Double, Boolean — use a wrapper or initialize directly)
Checking Whether a lateinit Is Initialized
#
Accessing a lateinit before initialization throws an UninitializedPropertyAccessException. Use ::propertyName.isInitialized to check before access:
class Service {
lateinit var client: HttpClient
fun sendRequest(url: String): String {
if (!::client.isInitialized) {
throw IllegalStateException("HTTP client not initialized. Call setup() first.")
}
return client.get(url)
}
fun setup(client: HttpClient) {
this.client = client
}
}
by lazy — Initialization on First Access
#
by lazy allows initialization to be deferred until the variable is first accessed. The given lambda block is executed only once — its result is stored and used for subsequent accesses.
val dbConnection: DatabaseConnection by lazy {
println("Opening database connection...") // printed only once
DatabaseConnection.create("jdbc:postgresql://localhost/mydb")
}
fun main() {
println("Application started")
// dbConnection isn't initialized yet here
val result = dbConnection.query("SELECT * FROM users") // initialization happens here
println(result)
val result2 = dbConnection.query("SELECT * FROM orders") // no re-initialization
println(result2)
}
Output:
Application started
Opening database connection...
[first query result]
[second query result]
When by lazy Is Better Than Direct Initialization
#
USE by lazy if:
✓ Initialization is expensive (DB connection, loading large files, complex parsing)
✓ The value might never be used — defer until it's definitely needed
✓ The value needs context that isn't available when the object is created
✓ You want thread-safety guarantees for initialization (the default lazy mode)
DON'T use by lazy if:
✗ Initialization is lightweight — the lazy overhead isn't worth it
✗ The value is always used — just initialize directly
✗ You need var — lazy is only for val
by lazy Thread-Safety Modes
#
by lazy by default uses LazyThreadSafetyMode.SYNCHRONIZED — only one thread can initialize, other threads wait. There are three available modes:
// SYNCHRONIZED (default): safe in multi-threaded contexts, slight overhead
val data by lazy { loadData() }
// PUBLICATION: multiple threads can initialize, but only one result is used
val data by lazy(LazyThreadSafetyMode.PUBLICATION) { loadData() }
// NONE: no synchronization — use only if you're certain it's single-threaded
val data by lazy(LazyThreadSafetyMode.NONE) { loadData() }
Destructuring Declarations #
Kotlin lets you extract multiple values from an object into separate variables in a single line. This is called a destructuring declaration.
// From a Pair
val coordinate = Pair(10.5, 106.8)
val (latitude, longitude) = coordinate
println("Latitude: $latitude, Longitude: $longitude")
// From a data class
data class Point(val x: Int, val y: Int, val label: String)
val point = Point(3, 7, "A")
val (x, y, label) = point
println("Point $label is at ($x, $y)")
// In a loop — very commonly used with Map
val dictionary = mapOf("id" to "Indonesia", "en" to "English", "ja" to "Japanese")
for ((code, language) in dictionary) {
println("$code → $language")
}
If there’s a component you don’t need, use _ as a placeholder:
val (_, longitude) = coordinate // ignore latitude
val (x, _, label) = point // ignore y
Variable Naming Conventions #
Kotlin follows the camelCase convention for variables and properties:
// CORRECT: camelCase
val fullName = "Budi Santoso"
var productCount = 0
val profileImageUrl = "https://cdn.example.com/photo.jpg"
// ANTI-PATTERN: snake_case (a Python/SQL convention, not Kotlin)
val full_name = "Budi Santoso"
var product_count = 0
// ANTI-PATTERN: PascalCase (a convention for classes, not variables)
val FullName = "Budi Santoso"
Variable names should be descriptive but not wordy. Avoid unclear abbreviations:
// ANTI-PATTERN: too short and unclear
val n = "Budi"
val t = 30_000
val f = getList()
// CORRECT: descriptive
val username = "Budi"
val timeoutMs = 30_000
val productList = getList()
For Boolean variables, use prefixes that reflect the true/false value:
// CORRECT: clear prefixes for Booleans
val isActive = true
val isVerified = false
val canEdit = true
val hasConnection = false
lateinit vs by lazy Comparison
#
| Aspect | lateinit | by lazy |
|---|---|---|
| Keyword | var | val |
| Initialization time | Any time before access | On first access |
| Who initializes | Your code, explicitly | The lambda you define |
| Thread-safety | None (your responsibility) | Yes (SYNCHRONIZED by default) |
| Can be checked | ::prop.isInitialized | Not needed (always available after first access) |
| Supported types | Non-nullable reference types | All types |
| Common use cases | Dependency injection, Android lifecycle | Expensive computations, resource connections |
Summary #
valis the default — usevalfor all variables whose value doesn’t need to change after being set. Switch tovaronly when there’s a concrete reason.vallocks the reference, not the object’s contents —val list = mutableListOf(...)still allows the list’s contents to be modified; only thelistreference itself is locked.- Type inference reduces redundancy — no need to write the type if it’s clear from the value. Write it explicitly only for clarity or when the desired type differs from what would be inferred.
- Variable scope is bounded by its block — a variable can only be accessed inside the
{ }block where it’s declared. Avoid shadowing (same name in different scopes) to keep code readable.- Null safety starts at declaration — types without
?can’t be null. Add?only if the value can genuinely be null, and handle it with?.,?:, orlet.- Smart cast simplifies nullable handling — after an explicit null check, Kotlin automatically treats the variable as non-nullable within that block.
lateinitfor injection and lifecycle — use it when the framework initializes the value, not your code at construction time. Always useisInitializedbefore access if there’s any doubt.by lazyfor expensive initialization — the value is computed only when first needed, cached, and thread-safe by default. Ideal for database connections, loading configuration, or heavy computations.- Destructuring — extract multiple values from a data class, Pair, or Map entry into separate variables in one line. Use
_for components you don’t need.