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:

OperatorEquivalent toExample
=Direct assignmentx = 5
+=x = x + valuex += 3
-=x = x - valuex -= 3
*=x = x * valuex *= 3
/=x = x / valuex /= 3
%=x = x % valuex %= 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 FunctionJavaOperation
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 division7 / 2 = 3, not 3.5. Convert one operand to Double if you need a decimal result.
  • == vs ===== compares values (calls equals()); === 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.
  • Ranges1..5 (inclusive), 0 until 5 (right-exclusive), 5 downTo 1 (descending), 1..10 step 2 (with a step). Use in for, when, and in conditions.
  • is and smart cast — after value is String, the compiler automatically treats value as String without an explicit cast.
  • Bitwise uses infix — in Kotlin, and, or, xor, shl, shr are infix functions, not symbols like &, |, ^ as in Java.
  • Operator overloading — operators in Kotlin are named functions (plus, minus, times, etc.). Mark with operator to allow symbol usage on custom classes.
  • Custom infix operators — mark a function with infix to 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.

← Previous: Data Types   Next: Conditional Statements →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact