Kotlin Introduction
Kotlin is a modern, statically-typed programming language that runs on the Java Virtual Machine (JVM), can compile to JavaScript, and can even produce native binaries for platforms without a JVM. It was created by JetBrains and became Google’s officially preferred language for Android development in 2019. Kotlin was designed to fix the pain points Java developers live with every day — verbose boilerplate, unchecked null pointer exceptions, and clunky functional programming — while staying fully interoperable with existing Java code and libraries. If you know Java, most of what follows will feel familiar but noticeably shorter and safer; if you don’t, Kotlin is still one of the friendliest statically-typed languages to start with.
Overview: What Kotlin Is and How It Works
Kotlin is statically typed, which means every variable, parameter, and return value has a type that the compiler checks before your program ever runs. Unlike Java, though, you rarely have to write those types out yourself — Kotlin’s type inference figures out that val count = 5 is an Int and val name = "Ada" is a String without any annotation. You write a type explicitly mainly when you want to document intent, when the compiler cannot infer it, or when you are declaring something as nullable.
When you compile Kotlin with the kotlinc compiler, your .kt source files are turned into JVM bytecode .class files, exactly like javac does for Java. That bytecode runs on the JVM, which is why Kotlin can call Java libraries directly and Java can call Kotlin code directly — they share the same runtime and the same object model. This is also why a Kotlin program does not need a wrapping class the way old-style Java did: a fun main() written at the top level of a file is compiled into a hidden class behind the scenes, but you never have to see or write that class yourself.
The single feature Kotlin is best known for is null safety, built directly into the type system. In Java, any reference type can silently be null, and the compiler does not stop you from calling a method on it — that mistake is caught only at runtime, as a NullPointerException. In Kotlin, a type like String can never hold null; only its nullable counterpart String? can. If you have a String?, the compiler refuses to let you call .length on it directly — you must first prove, using a safe call (?.), an Elvis operator (?:), a let block, or an explicit null check, that the value is not null. This moves an entire category of runtime crashes into compile-time errors, and it is the single biggest reason teams migrate to Kotlin.
Kotlin also blends object-oriented and functional programming. Classes, interfaces, and inheritance work much like Java, but functions are first-class values, lambdas have concise syntax, and expressions such as if and when can produce values directly instead of only branching. Combined with features like data classes and extension functions, this lets you write code that is shorter and reads closer to the problem you are solving, without sacrificing the safety of a compiled, statically-typed language.
Syntax
A Kotlin file’s basic shape is an optional package declaration, optional import statements, and then any number of top-level declarations — functions, classes, properties. Every standalone program needs exactly one fun main(), which is where execution begins.
// Optional package declaration
package com.example
// Optional imports
import kotlin.math.max
// Top-level function - the entry point
fun main() {
// statements go here
val result = max(3, 7)
println(result)
}
- package com.example — optional; groups this file’s declarations into a namespace. Omit it and the file belongs to the default package.
- import kotlin.math.max — optional; brings a name from another package into scope so it can be used unqualified.
- fun main() — the entry point. No enclosing class is required, unlike classic Java’s
public static void main(String[] args). - Statements inside the braces run top to bottom, in order.
A few basic types you will use constantly, each with a nullable counterpart formed by appending ?:
| Type | Example literal | Nullable form |
|---|---|---|
| Int | val x = 42 | Int? |
| Double | val pi = 3.14 | Double? |
| Boolean | val ok = true | Boolean? |
| String | val s = “hi” | String? |
| Char | val c = ‘A’ | Char? |
Examples
Example 1: Variables and String Templates
This program shows the difference between val (read-only) and var (reassignable), and how string templates embed values and expressions directly inside a string.
fun main() {
val name = "Ada"
var age = 30
age += 1
println("Name: $name")
println("Age next year: $age")
println("Sum: ${2 + 3}")
}
Output:
Name: Ada
Age next year: 31
Sum: 5
name is declared with val because it never changes, while age uses var because it is reassigned on the next line with age += 1. Inside each string, $name and $age are string templates — Kotlin evaluates the expression and substitutes its value. For anything more than a single identifier, such as 2 + 3, wrap the expression in ${...}.
Example 2: Nullable Types and the Elvis Operator
This example shows a nullable String? and two of the safest ways to work with a value that might be null: the Elvis operator (?:) and the safe-call operator (?.).
fun main() {
val name: String = "Kotlin"
var version: String? = null
println("Learning $name")
println("Version: ${version ?: "unknown"}")
version = "2.0"
val length = version?.length ?: 0
println("Version string length: $length")
}
Output:
Learning Kotlin
Version: unknown
Version string length: 3
version starts as null, so ${version ?: "unknown"} uses the Elvis operator: if the left side is null, the expression falls back to the right side, "unknown". After version is reassigned to "2.0", version?.length safely calls .length only if version is not null; if it were still null, the whole expression would short-circuit to null, and the trailing ?: 0 supplies a default of 0 instead.
Example 3: A Realistic Program with Data Classes and when
This example is closer to real code: a data class models a user record, a function handles a nullable field safely, and a when expression maps a value to a category.
data class User(val name: String, val email: String?)
fun greet(user: User): String {
val emailPart = user.email?.let { " ($it)" } ?: " (no email on file)"
return "Hello, ${user.name}$emailPart"
}
fun describeAge(age: Int): String = when {
age < 13 -> "child"
age < 20 -> "teenager"
else -> "adult"
}
fun main() {
val alice = User("Alice", "alice@example.com")
val bob = User("Bob", null)
println(greet(alice))
println(greet(bob))
println("Alice is an ${describeAge(30)}")
println(alice == alice.copy())
}
Output:
Hello, Alice (alice@example.com)
Hello, Bob (no email on file)
Alice is an adult
true
User is a data class, so Kotlin automatically generates equals(), hashCode(), toString(), copy(), and component functions for destructuring — that is why alice == alice.copy() prints true: copy() produces a new User object with the same property values, and == compares those values structurally rather than checking whether it is literally the same object in memory. Inside greet, user.email?.let { ... } only runs the lambda if email is not null; when it is null, the Elvis operator on the outside supplies the fallback text instead. describeAge uses when as an expression, so its result is returned directly, and the compiler requires every possible case to be covered — that is why there is an else branch even though the numeric conditions could otherwise go on forever.
How It Works Step by Step
Before your program runs, kotlinc parses every .kt file, resolves every reference, and checks every type, including nullability. If any expression could produce a type mismatch — for example, assigning a String? to a String — compilation stops there and nothing runs until the whole program type-checks. Once compilation succeeds, the compiler emits JVM bytecode, the JVM loads it, and it invokes the generated entry point, which in turn calls your fun main().
Walking through Example 3: main first constructs alice and bob — creating a User instance runs the primary constructor and stores name and email as read-only properties. greet(alice) evaluates alice.email?.let { ... }; because alice.email is not null, the lambda runs and produces " (alice@example.com)", so the Elvis operator’s right side is never evaluated. greet(bob) evaluates bob.email?.let { ... }; because bob.email is null, the safe call short-circuits to null without ever invoking the lambda, and the Elvis operator supplies " (no email on file)" instead. Finally, describeAge(30) checks each when branch top to bottom: 30 < 13 is false and 30 < 20 is false, so execution falls through to else and returns "adult".
Common Mistakes
Mistake 1: Reaching for !! Instead of Handling Null
The not-null assertion operator !! tells the compiler “trust me, this is not null” and converts a nullable type to its non-null counterpart on the spot. If you are wrong, it throws a NullPointerException at runtime — the exact exception Kotlin’s type system exists to prevent. Using !! routinely defeats the purpose of null safety.
fun printLength(s: String?) {
println(s!!.length)
}
fun main() {
val input: String? = null
printLength(input)
}
Calling printLength(input) here compiles fine but crashes immediately, because s!! asserts that s is not null and throws the moment that assertion is false. There is no way to recover; the program terminates.
fun printLength(s: String?) {
println(s?.length ?: 0)
}
fun main() {
val input: String? = null
printLength(input)
}
The corrected version uses the safe-call operator ?. together with the Elvis operator ?: to supply a default value of 0 when s is null, so the program handles the missing value instead of crashing. Reserve !! for situations where you have already proven, through logic the compiler cannot see, that a value cannot be null — and even then, prefer restructuring the code so the compiler can see it too.
Mistake 2: Assuming val Makes a Collection Immutable
val only prevents the variable itself from being reassigned to point at a different object — it says nothing about whether the object’s contents can change. A mutable list assigned to a val can still have elements added, removed, or overwritten.
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers = mutableListOf(4, 5, 6)
println(numbers)
}
This does not compile. The error is “Val cannot be reassigned”, because numbers is a val, so the line numbers = mutableListOf(4, 5, 6) tries to point the reference at a new list, which val forbids.
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers.add(4)
numbers[0] = 100
println(numbers)
}
The corrected version keeps numbers as a val for the entire program, but mutates the list’s contents with add and index assignment, both of which are allowed because they change what is inside the object, not which object the variable points to. If you need the variable itself to be reassignable, use var; if you need the contents to be unchangeable too, use listOf to create a genuinely read-only list instead of a mutable one.
Best Practices
- Default to
val; reach forvaronly when a value must genuinely change, and be able to say why. - Model absence with a nullable type (
String?) instead of sentinel values like empty strings or-1. - Prefer
?.,?:, andletover!!; treat!!as a last resort and a sign the design could be clearer. - Use
wheninstead of chainedif/else iffor multi-branch value matching — it reads better, and as an expression the compiler forces you to handle every case. - Reach for a
data classwhenever you need a simple value holder; let the compiler generateequals,hashCode,toString, andcopyinstead of writing them by hand. - Use string templates (
"Hello, $name") instead of string concatenation. - Let type inference work for you; add explicit types mainly on public API signatures and where they improve readability.
- Remember that
==compares values by callingequals(), while===compares identity; use==for almost everything.
Practice Exercises
- Write a program that declares your name and birth year as
valproperties, computes your approximate age, and prints a sentence using string templates. - Write a function
fun fullName(first: String, middle: String?, last: String): Stringthat safely combines the three parts, omitting the middle name entirely (and the extra space) when it is null. Call it once with a non-null middle name and once with null, and print both results. - Create a data class
Book(val title: String, val author: String, val year: Int). Inmain, create oneBook, create a second one withcopy()that only changes the year, and print whether the two are equal with==and whether they are the same object with===.
Summary
- Kotlin is a statically-typed language that compiles to JVM bytecode (and beyond), is fully interoperable with Java, and needs no wrapping class around
fun main(). - Every type is non-null by default; a trailing
?marks a type as nullable, and the compiler enforces null checks before a nullable value can be used. valdeclares a read-only reference andvardeclares a reassignable one;valdoes not make a mutable collection’s contents immutable.whenused as an expression must be exhaustive; data classes auto-generateequals,hashCode,toString,copy, andcomponentNfunctions.==checks structural equality viaequals();===checks whether two references point to the same object.- Prefer safe calls, the Elvis operator, and
letover the crash-prone!!operator.
