Operators #
Operators are symbols or keywords that instruct the compiler to perform a specific operation on one or more values. In Kotlin, operators aren’t just syntax — most of them are functions that can be overloaded on custom classes. That means when you write a + b, Kotlin is actually calling a.plus(b). This opens up the possibility of redefining operator behavior on your own types. This article covers every category of Kotlin operators in depth: from the most basic like arithmetic and comparison, to the Kotlin-specific ones like null-safety operators, range operators, and infix operators.
Arithmetic Operators #
Arithmetic operators perform basic mathematical operations. All of them can be overloaded for custom classes.
val a = 17
val b = 5
println(a + b) // 22 — addition
println(a - b) // 12 — subtraction
println(a * b) // 85 — multiplication
println(a / b) // 3 — integer division (not 3.4!)
println(a % b) // 2 — remainder (modulus)
Integer Division vs Decimal Division #
This is a very common source of bugs for beginners. Division between two Ints always produces an Int — the decimal part is truncated, not rounded.
// ANTI-PATTERN: unintentional integer division
val average = 7 / 2 // 3 — not 3.5!
val percentage = 1 / 3 // 0 — not 0.333!
// CORRECT: convert one operand to Double first
val average = 7.0 / 2 // 3.5
val average2 = 7 / 2.0 // 3.5
val average3 = 7.toDouble() / 2 // 3.5
val percentage = 1.0 / 3 // 0.3333...
Unary Operators #
Unary operators work on a single operand:
val positive = 10
val negative = -positive // -10 — unary minus
val alsoPositive = +negative // -10 (unary plus doesn't change the value)
Assignment Operators #
Assignment operators store values into variables. All compound assignment forms (+=, -=, etc.) only work with var.
var score = 0
score = 100 // plain assignment
score += 50 // score = score + 50 → 150
score -= 30 // score = score - 30 → 120
score *= 2 // score = score * 2 → 240
score /= 4 // score = score / 4 → 60
score %= 7 // score = score % 7 → 4
Quick reference table:
| Operator | Equivalent to | Example |
|---|---|---|
= | Direct assignment | x = 5 |
+= | x = x + value | x += 3 |
-= | x = x - value | x -= 3 |
*= | x = x * value | x *= 3 |
/= | x = x / value | x /= 3 |
%= | x = x % value | x %= 3 |
Increment and Decrement Operators #
++ and -- increase or decrease a value by 1. Both come in two forms: prefix (before the variable) and postfix (after the variable).
var n = 5
// Postfix: the expression uses the value BEFORE the change
println(n++) // prints 5, then n becomes 6
println(n--) // prints 6, then n becomes 5
println(n) // 5
// Prefix: the expression uses the value AFTER the change
println(++n) // n becomes 6, then prints 6
println(--n) // n becomes 5, then prints 5
println(n) // 5
The prefix/postfix difference is most noticeable when used inside expressions:
var i = 3
// ANTI-PATTERN: mixing increment and expression on one line — hard to read
val result = i++ * 2 // result = 6, i becomes 4 — not intuitive
// CORRECT: separate the increment from the expression
i++
val result = i * 2 // clearer
Comparison Operators #
Comparison operators compare two values and return a Boolean. In Kotlin, == calls the equals() function — it doesn’t compare memory references like in Java.
val x = 5
val y = 3
println(x == y) // false — equal to (structural equality)
println(x != y) // true — not equal to
println(x > y) // true — greater than
println(x < y) // false — less than
println(x >= y) // true — greater than or equal
println(x <= y) // false — less than or equal
== vs === — Structural vs Referential Equality
#
This is an important difference that often confuses developers coming from Java:
val a = "Kotlin"
val b = "Kotlin"
val c = a
// == compares values (structural equality) — calls equals()
println(a == b) // true — same values
println(a == c) // true — same values
// === compares references (referential equality) — is it the exact same object?
println(a === b) // true/false — depends on JVM string interning
println(a === c) // true — c is the same reference as a
// A clearer example with a data class
data class Point(val x: Int, val y: Int)
val p1 = Point(3, 7)
val p2 = Point(3, 7)
val p3 = p1
println(p1 == p2) // true — x and y values are the same
println(p1 === p2) // false — two different objects in memory
println(p1 === p3) // true — the exact same reference
The compareTo Comparator Operator
#
The <, >, <=, >= operators actually call compareTo() behind the scenes. For custom types implementing Comparable, these operators work automatically:
data class Version(val major: Int, val minor: Int, val patch: Int) : Comparable<Version> {
override fun compareTo(other: Version): Int {
return compareValuesBy(this, other, { it.major }, { it.minor }, { it.patch })
}
}
val v1 = Version(1, 2, 0)
val v2 = Version(1, 3, 0)
println(v1 < v2) // true
println(v1 > v2) // false
println(v2 >= v1) // true
val versions = listOf(Version(2, 0, 0), Version(1, 9, 5), Version(1, 10, 0))
println(versions.sorted()) // sorted from smallest to largest
Logical Operators #
Logical operators combine Boolean expressions. Kotlin supports short-circuit evaluation — evaluation stops as soon as the result is certain.
val a = true
val b = false
println(a && b) // false — AND: both must be true
println(a || b) // true — OR: one being true is enough
println(!a) // false — NOT: negation
Short-Circuit Evaluation #
Because Kotlin evaluates logical operators with short-circuiting, expression order affects both performance and safety:
// && stops at the first operand if its result is false
// Leverage this for safety checks before risky operations
val list: List<String>? = getList()
// ANTI-PATTERN: direct access without ordering checks
if (list != null && list.isNotEmpty() && list[0].length > 5) {
// safe because of short-circuit: if list is null, the next expression isn't evaluated
}
// || stops at the first operand if its result is true
// Put the most-likely-true condition on the left for efficiency
val cacheHit = getFromCache() || getFromDatabase()
// if getFromCache() is true, getFromDatabase() is never called
Bit-Level Logical Operators — and, or, xor as Infix
#
Unlike && and || which work on Booleans, Kotlin provides non-short-circuit versions as infix functions:
// Evaluates both without short-circuiting (rarely needed)
val both = true.and(false) // false
val either = false.or(true) // true
val different = true.xor(true) // false
Null-Safety Operators #
Kotlin has a set of special operators for working with nullable values — this is a feature that doesn’t exist in Java and significantly distinguishes Kotlin.
Safe Call Operator ?.
#
Accesses a member only if the object isn’t null. Returns null if the object is null.
var name: String? = getName()
// ANTI-PATTERN: repeated manual checks
if (name != null) {
if (name.isNotEmpty()) {
println(name.uppercase())
}
}
// CORRECT: chained safe calls
println(name?.takeIf { it.isNotEmpty() }?.uppercase())
// Long chaining — every step is safe
val firstNameLength = user?.profile?.firstName?.trim()?.length
Elvis Operator ?:
#
Provides a default value when the left expression is null:
val name: String? = getName()
val display = name ?: "Anonymous" // "Anonymous" if name is null
val length = name?.length ?: 0 // 0 if name is null
val valid = name?.isNotBlank() ?: false // false if name is null
// Elvis with throw — concise validation
val guaranteedAddress = address ?: throw IllegalStateException("Address is required")
// Elvis in a long chain
val city = user?.address?.city ?: "Unknown city"
Non-Null Assertion !!
#
Tells the compiler you’re certain the value isn’t null. Throws a NullPointerException if it turns out to be null.
var value: String? = "Definitely there"
// Use only if you're truly certain
val length = value!!.length // 8
// ANTI-PATTERN: careless !! — more dangerous than a Java NPE because you asked for it
fun processInput(input: String?) {
val result = input!!.trim() // ✗ crashes if input is null
}
// CORRECT: handle null gracefully
fun processInput(input: String?) {
val result = input?.trim() ?: return // exit the function if null
// continue processing the definitely non-null result
}
Every!!in your code is an admission that you’re taking over null-safety responsibility from the compiler. If you’re certain a value isn’t null because of program logic, it’s better to prove that to the compiler with smart casts or code restructuring — not with!!.
Range Operators #
Ranges are one of Kotlin’s most expressive features for declaratively defining value intervals.
// Inclusive at both ends: 1, 2, 3, 4, 5
val inclusiveRange = 1..5
println(3 in inclusiveRange) // true
println(6 in inclusiveRange) // false
// Exclusive at the right end: 0, 1, 2, 3, 4
val exclusiveRange = 0 until 5
println(5 in exclusiveRange) // false
println(4 in exclusiveRange) // true
// Backwards: 5, 4, 3, 2, 1
val backwardRange = 5 downTo 1
// With a step
val oddRange = 1..10 step 2 // 1, 3, 5, 7, 9
val descendingRange = 10 downTo 0 step 3 // 10, 7, 4, 1
// Character ranges
val letterRange = 'a'..'z'
println('m' in letterRange) // true
println('A' in letterRange) // false (case-sensitive)
Ranges in Loops #
Ranges are most often used with for:
for (i in 1..5) print("$i ") // 1 2 3 4 5
for (i in 0 until 5) print("$i ") // 0 1 2 3 4
for (i in 5 downTo 1) print("$i ") // 5 4 3 2 1
for (i in 0..20 step 5) print("$i ") // 0 5 10 15 20
Ranges in Conditions #
Ranges are very useful as when or if conditions:
val score = 82
// if with a range
val passed = score in 60..100
// when with ranges — more expressive than a long if-else chain
val grade = when (score) {
in 90..100 -> "A"
in 80..89 -> "B"
in 70..79 -> "C"
in 60..69 -> "D"
else -> "E"
}
println(grade) // B
The in and !in Operators
#
The in operator checks membership in a collection, range, or any type implementing contains().
val fruits = listOf("mangga", "apel", "jeruk")
println("apel" in fruits) // true
println("durian" in fruits) // false
println("durian" !in fruits) // true
// in on String — substring check
println("otlin" in "Kotlin") // true
println("java" in "Kotlin") // false
// in on Map — key check
val dictionary = mapOf("id" to "Indonesia", "en" to "English")
println("id" in dictionary) // true
println("fr" in dictionary) // false
The is and !is Operators — Type Checks
#
is checks whether an object is an instance of a certain type. After a successful is, Kotlin automatically performs a smart cast.
fun describe(value: Any): String {
return when (value) {
is String -> "String '${value.uppercase()}'" // value is already cast to String
is Int -> "Int: ${value * 2}" // value is already cast to Int
is Double -> "Double: ${\"%.2f\".format(value)}"
is Boolean -> if (value) "True" else "False"
is List<*> -> "List with ${value.size} elements"
else -> "Unknown type"
}
}
println(describe("halo")) // String 'HALO'
println(describe(42)) // Int: 84
println(describe(3.14)) // Double: 3.14
println(describe(listOf(1, 2, 3))) // List with 3 elements
// !is — the opposite of is
val text: Any = "Kotlin"
if (text !is Int) {
println("Not an integer")
}
Bitwise Operators #
Bitwise operators work on the binary representation of integers. In Kotlin, these operators are written as infix functions — not symbols like &, |, ^ in Java.
| Infix Function | Java | Operation |
|---|---|---|
and | & | Bitwise AND |
or | | | Bitwise OR |
xor | ^ | Bitwise XOR |
inv() | ~ | Inversion / bitwise NOT |
shl(n) | << | Shift left n bits |
shr(n) | >> | Shift right n bits (signed) |
ushr(n) | >>> | Shift right n bits (unsigned) |
val a = 0b1010 // 10 in decimal
val b = 0b1100 // 12 in decimal
println(a and b) // 0b1000 = 8 — AND: 1 if both are 1
println(a or b) // 0b1110 = 14 — OR: 1 if either is 1
println(a xor b) // 0b0110 = 6 — XOR: 1 if different
println(a.inv()) // -11 — inverts all bits
println(1 shl 3) // 8 — shift left 3 bits = multiply by 2³
println(16 shr 2) // 4 — shift right 2 bits = divide by 2²
println(-1 ushr 1) // 2147483647 — shift right without sign extension
Practical Bitwise Usage #
Bitwise operations are often used for flags and masks:
// Define flags as bits
const val PERMISSION_READ = 0b001 // 1
const val PERMISSION_WRITE = 0b010 // 2
const val PERMISSION_DELETE = 0b100 // 4
// Combine flags with OR
val adminPermissions = PERMISSION_READ or PERMISSION_WRITE or PERMISSION_DELETE // 7
val userPermissions = PERMISSION_READ or PERMISSION_WRITE // 3
// Check flags with AND
fun canRead(permissions: Int) = (permissions and PERMISSION_READ) != 0
fun canWrite(permissions: Int) = (permissions and PERMISSION_WRITE) != 0
fun canDelete(permissions: Int) = (permissions and PERMISSION_DELETE) != 0
println(canRead(userPermissions)) // true
println(canDelete(userPermissions)) // false
println(canDelete(adminPermissions)) // true
Infix Operators #
Kotlin allows defining functions that are called with infix notation — without a dot and parentheses. This makes code feel more natural and readable.
// to — creates a Pair, used in mapOf
val pair = "key" to "value" // Pair<String, String>
val map = mapOf("one" to 1, "two" to 2)
// until — creates an exclusive range
val range = 0 until 10
// step — step size in a range
val interval = 1..10 step 2
// downTo — descending range
val descending = 10 downTo 1
// and, or, xor — bitwise operations
val result = 5 and 3
You can also define your own infix functions with the infix keyword:
infix fun Int.isMultipleOf(n: Int): Boolean = this % n == 0
println(12 isMultipleOf 4) // true
println(7 isMultipleOf 3) // false
// Another example — DSL-style
infix fun String.togetherWith(other: String) = "$this and $other"
val sentence = "Kotlin" togetherWith "Java"
println(sentence) // Kotlin and Java
Operator Overloading #
In Kotlin, most operators are functions that can be overloaded on custom classes by marking the function with the operator keyword.
data class Vector(val x: Double, val y: Double) {
// Overload the + operator
operator fun plus(other: Vector) = Vector(x + other.x, y + other.y)
// Overload the - operator
operator fun minus(other: Vector) = Vector(x - other.x, y - other.y)
// Overload the * operator (multiplication by a scalar)
operator fun times(scalar: Double) = Vector(x * scalar, y * scalar)
// Overload the unary minus operator
operator fun unaryMinus() = Vector(-x, -y)
// Overload the == operator (via equals)
override fun equals(other: Any?): Boolean {
if (other !is Vector) return false
return x == other.x && y == other.y
}
val length get() = Math.sqrt(x * x + y * y)
override fun toString() = "($x, $y)"
}
val v1 = Vector(3.0, 4.0)
val v2 = Vector(1.0, 2.0)
println(v1 + v2) // (4.0, 6.0)
println(v1 - v2) // (2.0, 2.0)
println(v1 * 2.0) // (6.0, 8.0)
println(-v1) // (-3.0, -4.0)
println(v1 == v2) // false
println(v1.length) // 5.0
Operator Precedence #
When many operators are used in a single expression, the evaluation order is determined by precedence. Operators with higher precedence are evaluated first.
flowchart TD
A["Highest Precedence"] --> B["Postfix: ++, --"]
B --> C["Prefix: --, ++, -, +, !"]
C --> D["Multiplication: *, /, %"]
D --> E["Addition: +, -"]
E --> F["Range: .., until"]
F --> G["Infix: shl, shr, ushr, and, or, xor"]
G --> H["Elvis: ?:"]
H --> I["Comparison: <, >, <=, >=, in, !in, is, !is"]
I --> J["Equality: ==, !="]
J --> K["Conjunction: &&"]
K --> L["Disjunction: ||"]
L --> M["Spread: *"]
M --> N["Lowest Precedence: =, +=, -=, *=, /=, %="]// Precedence examples in practice
val result = 2 + 3 * 4 // 14 — multiplication first
val explicit = 2 + (3 * 4) // 14 — same, but more explicit
val logic = true || false && false // true — && has higher precedence than ||
// equivalent to: true || (false && false) = true || false = true
// Use parentheses for clarity, not memorized precedence
val clearer = true || (false && false) // same intent, easier to read
Memorizing the entire operator precedence table isn’t a good approach. Better to use parentheses explicitly when an expression involves more than two operators from different categories. Clear code is worth more than concise but ambiguous code.
Summary #
- Integer division —
7 / 2 = 3, not3.5. Convert one operand toDoubleif you need a decimal result.==vs===—==compares values (callsequals());===compares memory references. Almost always use==for value comparisons.- Short-circuit evaluation —
&&stops if the left operand is false;||stops if the left operand is true. Leverage this for safety checks: put the null-check condition on the left.- Null-safety operators —
?.for safe calls,?:for default values (Elvis),!!for non-null assertion (use with caution). This trio replaces verbose manual null checks.- Ranges —
1..5(inclusive),0 until 5(right-exclusive),5 downTo 1(descending),1..10 step 2(with a step). Use infor,when, andinconditions.isand smart cast — aftervalue is String, the compiler automatically treatsvalueasStringwithout an explicit cast.- Bitwise uses infix — in Kotlin,
and,or,xor,shl,shrare infix functions, not symbols like&,|,^as in Java.- Operator overloading — operators in Kotlin are named functions (
plus,minus,times, etc.). Mark withoperatorto allow symbol usage on custom classes.- Custom infix operators — mark a function with
infixto call it without a dot and parentheses, useful for creating readable DSLs.- Use parentheses for clarity — don’t rely on memorized operator precedence when expressions are complex. Explicit parentheses are clearer than ambiguous expressions.