intro-kotlin
IntelliJ with Kotlin
Kotlin requires the JDK (Java Development Kit) to run JVM applications like Kotlin.
Creating a new project
When creating a new project in Kotlin, make sure to follow these steps:

- Select console project
- Select new version of JDK
Now a main.kt file should be created

Basics
Variables
In kotlin, you use the var and val keywords to declare variables. Here are the differences between them:
var: mutable variablesval: immutable variable constants.
NOTE
So use var when you expect your variable to change values, and use val when you expect your variable to remain constant.
var name = "John"
val birthyear = 1975
You define constants with const val, but global constants are not allowed. You have to define them within an object
object TaxCalculator {
const val SALES_TAX = 8.2
}
But there is a difference between constants and immutable variables.
- constant: must be defined at compiled time, and is also immutable
- immutable: does not need to be defined at compile time, but you are not able to reassign it.
In summary:
var: defines a mutable variablesval: defines an immutable variablesconst val: defines a constant
Data types and type annotation
You can type annotate your variables like this in kotlin:
val name : String = "John"
Here is the list of data types in kotlin
StringIntDoubleCharBoolean
Null safety
Kotlin does not allow assigning null to non-nullable types by default to help prevent null pointer exceptions, which are a common source of bugs in many programming languages like Java.
NOTE
By making types non-nullable unless explicitly declared otherwise (with a question mark), Kotlin encourages safer code and reduces runtime crashes.
To let a variable also be possible null, you have to put a ? after the type annotation.
Of course, this means you have to use some operators when you want to access properties and methods on a potentially null variable.
var myName: String // What's the value? Null? IMPOSSIBLE
var myLastname: String? // It can handle a null value
non-null assertion operator
The !! before accessing a property overrides the compiler, and is basically the developer guaranteeing that a variable at that point in time will not be null.
Avoid using this if you can, because this leads to unsafe access.
// not-null assertion operator
val lengthForSure = myLastname!!.length
optional chaining operator
The ? before accessing a property will return null if the variable is null, and successfully do the property access if the variable is not null
// returns null if myLastname is null, else returns myLastname.length
val length = myLastname?.length
elvis operator
The ?: operator is used to specify a default value if the optional chaining with ? fails.
// returns name.count if name is not null, else returns 0
val lengthOrDefault = name?.count ?: 0
Numbers
In kotlin, you have many different numeric data types, depending on their size, sign, and decimal vs non-decimal type:
Int: 32-bit integersShort: 16-bit integersByte: 8-bit integersLong: 64-bit integers, referred to with anLsuffix after the number
For each of these integer types, you can also access the unsigned version by suffixing a U at the end of the number.
Then you have the decimal versions:
Float: 32-bit decimal, referred to with anfsuffix after the number
val int: Int = 0
val byte: Byte = 0
val short: Short = 0
val long: Long = 0
val uint: UInt = 0U
val ubyte: UByte = 0U
val ushort: UShort = 0U
val ulong: ULong = 0UL
val float: Float = 0.0f
val double: Double = 0.0
Also you don't have to explicitly type-annotate numbers as being a certain numeric type. Kotlin is smart enough to infer which specific numeric type a variable is supposed to be, depending on its value:
- Kotlin infers an integer literal as an Int by default if the value fits within 32 bits.
- If the value is larger than what 32 bits can hold, Kotlin infers it as a Long.
NOTE
You can also explicitly specify a Long literal by appending an 'L' to the number (for example, 10L). This helps Kotlin know you want the value treated as a Long even if it fits within 32 bits.
Type conversion
It is possible to convert any numeric type to any other numeric type.
- converting larger numeric type to smaller numeric type means losing precision
- converting smaller numeric type to larger numeric type is fine
To convert from one type to another, you have to use these methods belonging to a variable:
num.toInt(): returns the integer version of the numbernum.toDouble(): returns the double version of the number
Number methods
val int: Int = 0
val double: Double = 0.0
println(double.toInt())
println(int.toFloat())
Strings and chars
There are two types of alphabetic types in kotlin:
Char: represents a single character value, denoted via single quotesString: represents a multi-character value, denoted via double quotes
NOTE
In Kotlin, the plus operator can't concatenate two chars because a char is designed to hold only a single character, not multiple characters.
- When you try to combine two chars directly, the compiler throws an error since it expects a single character per char literal.
- To combine characters, you should use strings instead, which can hold multiple characters. You can concatenate strings using the plus operator or use Kotlin's string templates for more efficient and readable string formatting.
String builder
In Kotlin, strings are immutable, so whenever you concatenate strings together, in reality you're just creating a new string.
There are two ways to overcome this performance issue:
- Stringbuilder method: A
StringBuilderis a more performant way to construct strings in Kotlin. - Template string interpolation: creates one string and uses the string builder underneath the hood.
String basics
String access
str[n]: position based indexing
string properties
-
str.length: returns the length of the string
checking for string equality
The str1.compareTo(str2) method compares str1 to str2, and returns 0 if they are equal.
string methods
String-checking methods
These are methods to check if a string is empty or not
fun main(args: Array<String>) {
var str = args.contentToString()
println(str.isEmpty()) // returns true if empty string
println(str.isNotEmpty()) // returns true if not empty string
println(str.isBlank()) // returns true if only whitespace
println(str.isNullOrBlank()) // returns true if null or only whitspace
println(str.isNullOrEmpty()) // returns true if null or empty string
}
These are methods to check if a string contains a certain substring:
-
str.startsWith(prefix: String): Checks if the string starts with a specified prefix. -
str.endsWith(suffix: String): Checks if the string ends with a specified suffix. -
str.contains(charSequence: String): Checks if the string contains a specified character sequence.
String manipulation methods
-
str.toUpperCase(): Converts all characters in the string to uppercase. -
str.toLowerCase(): Converts all characters in the string to lowercase. -
str.substring(startIndex: Int): Returns a substring starting from the specified index. -
str.substring(startIndex: Int, endIndex: Int): Returns a substring within the specified range. -
str.trim(): Removes leading and trailing whitespaces from the string. -
str.replace(oldstr: String, newstr: String): Replaces occurrences of a specified character sequence. -
str.indexOf(substr: String): returns the index of the first occurrence of the specified substring within the string.
var txt = "Hello World"
println(txt.toUpperCase()) // Outputs "HELLO WORLD"
println(txt.toLowerCase()) // Outputs "hello world"
Multi-line strings and template interpolation
You can create multiline strings with """
You can use template string interpolation with $ if just interpolating a variable, or with ${} if interpolating an expression.
var bruh = "bruh"
var multistring = """
|This is a multiline string
|with $bruh, whose age is ${2*2+15}
|
"""
var firstName = "John"
var lastName = "Doe"
println("My name is $firstName $lastName")
Conditionals
==: for structural equality===: for referential equality
Boolean operators
&&: logical AND||: logical OR!: logical NOT
logical flow
If/else
if (condition1) {
// block of code to be executed if condition1 is true
} else if (condition2) {
// block of code to be executed if the condition1 is false and condition2 is true
} else {
// block of code to be executed if the condition1 is false and condition2 is false
}
While loop
You can do a normal while loop like so:
while (condition) {
// code block to be executed
}
Or use a do/while loop, where the code in the do block runs before the while condition is evaluated, so the code block always runs at least once, even if while condition is not true
var counter = 0
// while always run at least once, even if `while` condition is not true
do {
println("Counter is $counter")
counter++
} while (counter < 0)
When statements
when statements are basically if ternary operators and switch statements fucked and had a baby.
Here are the rules:
- The "default" case in a
whenstatement is anelseblock. - Instead of single-statement case blocks with
->, you can expand each case to be a multi-statement case with-> {}syntax.
Here's a full example:
var someVariable = 0
when {
someVariable > 3 -> println("The value was greater than 3")
someVariable > 2 -> println("The value was greater than 2")
else -> {
println("Not greater")
}
}
when (someVariable) {
0, 1 -> println("The value was 0 or 1")
2 -> println("The value is 2")
3 -> println("The value is 3")
in 4..Int.MAX_VALUE -> println("The value was greater than 3")
}
try/catch
Here is a basic try-catch:
try {
println("Hello World!")
}
catch (e: Exception) {
println(e.message)
}
But it gets even more interesting when we use ternary expressions as shown in the next section.
Ternary expressions
Ternary expressions offer syntactic sugar over retrieving a value from if/else logic or when logic and store that in a variable
Here are the two main use cases for ternary expressions:
- conditionally storing a value for a variable: immediately store different values in a variable depending on a condition.
- returning a conditional value from a function: immediately different values from a function depending on a condition.
if/else ternary
With this ternary expression in Kotlin, we can directly use if/else logic to set the value of a variable.
var myvar = if (condition) {
// value if condition is true
} else {
// value if condition is false
}
You can even shorten this to a more familiar Python-ish ternary expression:
var myvar = if (condition) value_if_true else value_if_false
var greeting = if (14 < 18) "Good day." else "Good evening."
Since ternary expressions just return a value, you can also set it as an immediate return value for a function, making for extremely concise syntax:
fun getMessage(input: Int) = if (input > 3) {
"Greater than 3"
} else {
"Not greater than 3"
}
when ternary
var day = 4
var result = when (day) {
1 -> "Monday"
2 -> "Tuesday"
3 -> "Wednesday"
4 -> "Thursday"
5 -> "Friday"
6 -> "Saturday"
7 -> "Sunday"
else -> "Invalid day."
}
println(result)
Since ternary expressions just return a value, you can also set a when ternary as an immediate return value for a function, making for extremely concise syntax:
fun getMessageWithWhen(input: Int) = when (input) {
3 -> "Value is 3"
else -> "Value is not 3"
}
try/catch ternary
A try/catch ternary allows you to try returning a certain value in a try block, and if that throws an error, then it returns the return value from the catch block
// stores string
val message = try {
"The value is ${10 / 0}"
} catch (error: Throwable) {
"Error was thrown"
}
You can also handle more specific errors with multiple catch blocks:
val message = try {
throw IllegalStateException()
"The value is ${10 / 0}"
} catch (error: ArithmeticException) {
"Error was thrown"
} catch (error: java.lang.IllegalStateException) {
"Error was IllegalState"
}
println(message)
Type casting
as: type cast a variable to another type or class type, throwsClassCastExceptionif it can't complete the cast.as?: type cast a variable to another type or class type safely, instead of throwing aClassCastExceptionif it can't complete the cast, it returnsnullis: boolean check to see if a variable is of a certain type or is an object instance of a class.
Type casting with as
/**
* PRINTS:
* ----------
* can't cast string to int
* able to successfully cast string to int false
*/
fun main(args: Array<String>) {
// able to cast broad class type "Any" to narrow class type "Int", if value is int
var generic: Any = 5
var int = generic as Int
val failedToCastStringToNumber = try {
// not able to cast string value to int
var generic2: Any = "string"
var int2: Int = generic2 as Int
true
} catch (e: ClassCastException) {
println("can't cast string to int")
false
}
println("able to successfully cast string to int $failedToCastStringToNumber")
}
Type checking with is
The is keyword allows you to check if a variable is a certain data type or object instance.
You can also negate an is statement with !is to check if a variable is NOT a certain data type of object instance.
fun checkType(input: Any) {
if (input is String) {
println("Input is a String")
}
if (input !is Int) {
println("Input is not an Int")
}
}
fun main() {
val aGenericVariable: Any = 5
checkType(aGenericVariable)
}
Smart casting
Smart casting is a compiler feature in Kotlin that automatically tracks your type checks (is and !is) and converts a generic reference to a specific type. This eliminates the need for redundant, explicit casting operators (like Java's old casting syntax or Kotlin's explicit as operator).
The Kotlin compiler uses flow-sensitive analysis (also known as data-flow analysis) to monitor the execution path of your code.
Here's an example of how smart casting helps you scope down any generic type to a specific type like a string:
- The Condition Check: When the compiler hits an
if (input is String)block, it verifies that the execution path inside theifbrackets is only accessible ifinputis truly aString. - The Scope Update: Inside that specific branch, the compiler changes its internal metadata for
input, treating it as aStringrather thanAny. - Automatic Access: You can immediately call
Stringfunctions (like.lengthor.lowercase()) without writing a manual cast.
fun process(input: Any) {
if (input is String?) {
if (input == null || input?.length == 0) {
return
}
// The compiler smart-casts 'input' to non-null String here
println(input.length)
}
// Outside the block, 'input' is back to being 'Any'
// println(input.length) // Error!
}
The compiler's flow tracking extends beyond simple if blocks to logical expressions and control flows.
1. Inside Conditional Expressions (&&)
Because the logical AND operator (&&) evaluates from left to right and short-circuits, the compiler knows the right side of the expression will only execute if the left side evaluates to true.
// Smart cast happens right inside the condition!
if (input is String && input.length > 5) {
println("Long string")
}
2. Early Returns (!is)
If you invert the check using !is and exit the function early via a return, throw, or break, the compiler recognizes that any code executing after that check must have a valid type.
fun printLength(input: Any) {
if (input !is String) return // Guard clause exits if not a String
// The compiler knows execution can only reach here if it IS a String
println(input.length) // Perfectly valid smart cast
}
3. Inside when Expressions
When you use type checks as branches inside a when statement, the compiler handles each scope independently.
fun evaluate(input: Any) = when (input) {
is Int -> input + 10 // Smart cast to Int
is String -> input.uppercase() // Smart cast to String
else -> "Unknown type"
}
When smart casting fails
Smart casting relies completely on the compiler's guarantee that the variable's value cannot change between the type check and its subsequent usage. If the compiler cannot prove a variable is immutable, it disables smart casting for safety.
Smart casting is forbidden in the following scenarios:
- Mutable Local Variables (
var): If a localvaris modified inside a concurrent lambda or modified between the type check and the usage, the compiler throws an error. - Open/Mutable Properties (
valorvarfields): If a property belongs to a class and is accessible by other threads or classes, its state cannot be guaranteed. A custom getter (val x: Any get() = ...) can return a different type every time it is called, making smart casting unsafe.
class Demo {
var mutableProperty: Any = "Hello"
fun unstableCheck() {
if (mutableProperty is String) {
// ERROR: Smart cast is impossible because 'mutableProperty'
// could be modified by another thread right now!
// println(mutableProperty.length)
}
}
}
Safe casts (combining as? with ?:)
In Kotlin, combining the safe cast operator (as?) with the Elvis operator (?:) is the standard, idiomatic way to handle type casting while safely providing a default fallback behavior or exiting a execution flow when a type mismatch occurs.
as: type cast a variable to another type or class type, throwsClassCastExceptionif it can't complete the cast.as?: type cast a variable to another type or class type safely, instead of throwing aClassCastExceptionif it can't complete the cast, it returnsnull
val obj: Any = 123
val str: String? = obj as? String // Fails, evaluates to null (no crash)
The Elvis operator (?:) checks the value on its left side. If that value is not null, it returns it. If the value on its left side is null, it executes and returns the expression on its right side.
val name: String? = null
val displayName = name ?: "Guest" // Evaluates to "Guest"
When you chain them together (obj as? Type ?: fallback) here is what happens:
- the safe cast attempts to run first.
- If it returns
nulldue to a type mismatch, the Elvis operator intercepts thatnulland runs its fallback code.
You can use this combination to safely parse generic data structures (like a JSON map or configuration bundle) and guarantee a fallback value if the data type isn't what you expected.
fun getMultiplier(configValue: Any): Int {
// Attempt to cast to Int; if it fails (returns null), default to 1
return configValue as? Int ?: 1
}
fun main() {
println(getMultiplier(5)) // Output: 5 (Cast succeeds)
println(getMultiplier("hello")) // Output: 1 (Cast fails, Elvis falls back)
}
Pattern B: Guard Clauses and Early Returns
In application development, this pattern is frequently used to validate inputs at the top of a function. If the passed argument is not the expected type, you can use return or throw on the right side of the Elvis operator to stop execution.
fun processPayload(payload: Any) {
// Guard clause: Safe cast to String or exit the function immediately
val text = payload as? String ?: return
// The compiler now knows 'text' is a non-nullable String
println("Processing text of length: ${text.length}")
}
fun criticalOperation(data: Any) {
// Guard clause: Safe cast to User or crash with a specific error
val user = data as? User ?: throw IllegalArgumentException("Invalid user profile data provided")
// Safely proceed with the 'user' object
println("Logged in as ${user.username}")
}
Summary Comparison of Casting Strategies
| Code Pattern | If Cast Succeeds | If Cast Fails | Safety Rating |
|---|---|---|---|
obj as String | Returns String | Throws ClassCastException 💥 | Dangerous |
obj as? String | Returns String? | Returns null | Safe, but leaves type nullable |
obj as? String ?: "" | Returns String | Returns default value "" | Excellent (Idiomatic) |
obj as? String ?: return | Returns String | Exits function early | Excellent (Idiomatic) |
Functions
When returning something in a function, you need to provide type annotations for both the parameters and the return type.
fun aFunctionReturning(x: Int): String {
return "I'm a function $x"
}
NOTE
The return type can be inferred from the type of what is being returned.
When passing in arguments, you can do these pythonic things:
- set default values for parameters
fun greet(name: String = "Aadil") {
println("Hello $name")
}
greet()
- use named keyword arguments, which allow you to specify argument in whatever order you want, as long as they are all named.
fun greet(name: String) {
println("Hello $name")
}
greet(
name="Aadil"
)
Void functions
Here is an example of a void function, where if you don't return anything, it returns the inferred Unit type:
fun voidfnInferred() {
println("Hello World!")
}
fun voidfnExplicit(): Unit {
println("Hello World!")
}
Basic functions
Level 1: basic function
fun sum(x: Int, y: Int) : Int {
return x + y
}
Level 2: implicit return
If your function is a one-liner, you can skip the {} and the return statement, and instead use an =.
fun sum(x: Int, y: Int) : String = x + y
Level 3: immediate return, inferred returned type
You can return a value straight up, inferring the return type from the return value
fun greeting() = "hello"
fun salute(name: String) = "hello $name"
Lambda functions
Lambda functions are syntactic sugar over creating a function by storing the function directly as a variable.
Here are some rules to understand about lambdas:
- Return value: The return value of a lambda is whatever the last value referenced in a lambda function is, because there is no
returnstatement allowed in a lambda.
The basic syntax of a lambda function is to type annotate it as an arrow function, like () => ReturnType, and then set it equal to a pair of {} and type your code inside, like this, via two ways:
- explicit lambda type annotation: giving a type annotation for the function, which gives type annotations for both the function arguments and return type.
- implicit lambda type annotation: inferring the return type annotation, which is only possible if you don't have any arguments.
// level 1: explicit lambda type annotation
val myFunc_level1: () -> String = {
"hello"
}
// level 2: implicit lambda type annotation,
// inferred as () -> String type function
val myFunc_level2 = {
"hello"
}
// level 3: implicit lambda type annotation
// inferred as (myvar: string) -> String type function
val myFunc_level3 = { myvar : String ->
"hello $myvar"
}
If you don’t plan on returning something, type annotate the return type as Unit, Kotlin’s version of void
val greet: () -> Unit = {
println("hello")
}
Level 2: lambda function with parameters
The weird thing here is that in the return type annotation, you don’t specify the arguments, you just specify the type of the arguments, and then you actually define the arguments within the code block itself.
In the example below, the (Int, Int) type annotates and specifies two integer parameters for the lambda, and then we name them within the code block as x, y, with no return statement
.
val sum: (Int, Int) -> Int = { x, y ->
x + y
}
Level 3: implicit it
When you only have one argument in a lambda function, it will be named it by default and you don’t have to define it within the code block like you had to do for multiple parameters.
// implicit `it` argument when there is only one argument
// you can still name it if you want to
val greet: (String) -> String = {
"Hello $it"
}
Functions as first-class objects
Here is an example of functions being considered as objects:
fn.invoke(varargs Any): invoke the function, pass in the required arguments.
// create a lambda with void typing
var voidfn: () -> Unit = {
println("Hello world!")
}
fun main(args: Array<String>) {
// these two do the same thing
voidfn()
voidfn.invoke()
val greet = {name: String ->
"hello $name"
}
greet("Aadil")
greet.invoke("Aadil")
}
Passing in functions as parameters
When passing in functions as parameters to another function, the type annotation of that function argument will be enough to just pass the function object in as is.
If a function is the last argument in a function header, then you can use trailing lambda syntax, where you can add the lambda outside the parentheses of the supplied parameters.
fun printCalculatedValue(value1: Int, value2: Int, calculator: (Int, Int) -> Int) {
println("The value is: ${calculator(value1, value2)}")
}
fun main() {
// trailing lambda syntax
printCalculatedValue(2, 2) { value1, value2 ->
value1 + value2
}
// normal
printCalculatedValue(2, 2, { value1, value2 ->
value1 - value2
})
}
Here's an example:
fun printFormattedName(fname: String, lname: String, formatName: (s1: String, s2: String) -> String) {
var formattedName = formatName(fname, lname)
println(formattedName)
}
fun main(args: Array<String>) {
printFormattedName("Aadil", "Mallick") { fname, lname ->
"$fname porky $lname"
}
}
Variable number of arguments
By declaring a parameter in your function with the vararg keyword, you are letting kotlin know that you intend to pass an arbitrary amount of arguments, and it will get stored in one variable as a list.
fun add(vararg numbers: Int) : Int {
var sum = 0
for (number in numbers) {
sum += number
}
return sum
}
This is also powerful when combined with arrays, since you can either pass in the array as is to a varargs argument or spread it out.

Loops and iteration
Ranges and infix functions
In kotlin, a range is like 1..5, which creates an iterable of numbers, but you can also loop through a range of chars:
// LEVEL 1: loop through number sequence
for (nums in 5..15) {
println(nums)
}
// LEVEL 2: loop through char sequence
for (chars in 'a'..'x') {
println(chars)
}
In kotlin, you can use infix functions to generate iterables on the fly:
for (i in 1..10) {
println("Counter: $i")
}
// inclusive with step
for (i in 1..10 step 3) {
println("Counter: $i")
}
// exclusive
for (i in 1 until 10) {
println("Counter: $i")
}
// exclusive with step
for (i in 1 until 10 step 3) {
println("Counter: $i")
}
// inclusive decrement
for (i in 10 downTo 1) {
println("Counter: $i")
}
// inclusive decrement with step
for (i in 10 downTo 1 step 3) {
println("Counter: $i")
}
casting range to a list
We wrap a range in parenthesis and then call the toList() or toMutableList() methods to cast the range into a list
var myList = (1..20).toList()
stdout
println: (print line) displays a string followed by a new line on the screen.print: function displays a value and places the cursor after.
Generics and types
In Kotlin, ==generics allow you to write reusable code by parameterizing types== (e.g., creating a List<T> instead of separate list classes for every data type).
However, Kotlin targets the Java Virtual Machine (JVM), which enforces Type Erasure. Understanding how Kotlin ensures type safety while navigating this JVM limitation requires looking at compile-time checks, runtime constraints, and Kotlin-specific keywords like inline and reified.
How generics work
In Kotlin, ==generics allow you to write reusable code by parameterizing types== (e.g., creating a List<T> instead of separate list classes for every data type).
However, Kotlin targets the Java Virtual Machine (JVM), which enforces Type Erasure. Understanding how Kotlin ensures type safety while navigating this JVM limitation requires looking at compile-time checks, runtime constraints, and Kotlin-specific keywords like inline and reified.
Type erasure
Type erasure means that generic type arguments are only used during compile time. Once the compiler finishes verifying that your code is type-safe, it strips out the generic type parameters. When the application runs on the JVM, the type information is gone.
- At Compile Time: The compiler sees your object as a
List<String>. - At Runtime: The JVM only sees a raw
List(or anArrayList).
Because the JVM doesn't know what kind of data is supposed to live inside the list at runtime, the compiler injects explicit type casts behind the scenes whenever you fetch an item from a generic collection to guarantee type safety.
consequences of type erasure
Because type arguments are erased at runtime, you cannot perform runtime type checks (is) or explicit casts (as) on generic types with specific arguments.
fun checkList(data: Any) {
// ERROR: Cannot check for instance of erased type: List<String>
if (data is List<String>) { ... }
// VALID: You can only check if it is a List of unspecified elements (Star Projection)
if (data is List<*>) { ... }
}
Similarly, if you try to force a cast using an erased type, the compiler will emit an Unchecked Cast Warning. It warns you that it cannot guarantee safety at runtime:
fun extractStrings(data: Any): List<String> {
// Warning: Unchecked cast: Any to List<String>
return data as List<String>
}
If data actually contains a List<Int>, the cast statement above will succeed without error because the JVM only checks if it is a List. However, the moment your code tries to pull an item out of that list and treat it as a String, your app will crash with a ClassCastException.
How to solve type erasure
To bypass type erasure when you absolutely need access to type arguments at runtime, Kotlin introduces Reified Type Parameters.
This feature allows you to preserve generic type arguments, but it comes with a strict requirement: the function must be inline.
Here's how it works
- When a function is marked as
inline, the Kotlin compiler copies the function's actual bytecode directly into the location where the function is called. - Because the compiler knows the exact type argument being passed at that specific call site, it can substitute the generic
Twith the true class type.
// By marking T as reified, the type metadata is preserved in the generated bytecode
inline fun <reified T> Any.isInstanceOf(): Boolean {
return this is T // Perfectly legal! No type erasure.
}
fun main() {
val name: Any = "Kotlin"
// Under the hood, the compiler rewrites this line as: name is String
println(name.isInstanceOf<String>()) // Output: true
println(name.isInstanceOf<Int>()) // Output: false
}
Variance: out and in
To maintain absolute compile-time type safety with generics, Kotlin uses a concept called Variance. This governs how generic types with subtyping relationships interact (e.g., whether a List<String> can be safely passed to a function expecting a List<Any>).
Kotlin enforces this at the declaration level using the out and in keywords:
Covariance (out)
- Keyword:
out T - Rule: The type parameter can only be produced/returned by the class, never consumed as a function parameter.
- Behavior: If
StringextendsAny, thenBox<String>is a subtype ofBox<Any>. - Example: Kotlin’s default
List<out T>is immutable, making it safe to treat a list of strings as a list of any objects.
interface Source<out T> {
fun nextItem(): T // Valid: T is in the 'out' position
// fun saveItem(item: T) // Error: T cannot be in the 'in' position
}
Contravariance (in)
- Keyword:
in T - Rule: The type parameter can only be consumed/passed into functions, never returned.
- Behavior: If
StringextendsAny, thenConsumer<Any>is a subtype ofConsumer<String>(the relationship is inverted).
interface Sink<in T> {
fun consumeItem(item: T) // Valid: T is in the 'in' position
// fun produceItem(): T // Error: T cannot be in the 'out' position
}
Star projection
When using generics and you don’t care about the type of a certain collection, just use * to signal that you don’t need the type.
// star projection in array
fun printArray(array: Array<*>) {
array.forEach { print(it) }
}
val name = arrayOf("Geeks","for","Geeks")
printArray(name)
Summary
| Feature | When it Happens | Purpose |
|---|---|---|
| Compile-Time Checks | Compilation | Guarantees that generic constraints are strictly honored before code turns into bytecode. |
| Type Erasure | Runtime (JVM Execution) | Strips generic arguments to maintain backwards compatibility with standard Java execution. |
Star Projection (*) | Runtime / Compile | Allows safe runtime checks against raw structures (List<*>) without specifying arguments. |
| Reified Parameters | Compilation (Inlining) | Injects true concrete types directly into the calling bytecode to bypass erasure constraints. |
Reflection
Kotlin reflection refers to how we can get references from classes and functions to pass around as references to other places in our code.
Getting class reference
Use the ::class operator syntax to get back the unique referece that represents that class.
val classReference = ClassName::class
Getting function reference
You can get a function reference object back with this syntax: ::functionName
Collections
Pair and Triple
The Pair and Triple data types were made as a way to return multiple values from a function without having to create data classes just to correctly annotate the return type.
Pair
Here are the properties you can access on a pair:
pair.first: returns the first elementpair.second: returns the second element.
NOTE
In map collections, the MapEntry<K, V> instance can be derived from a pair, but we use pair.key and pair.value instead.
Triple
Here are the properties you can access on a triple:
triple.first: returns the first elementtriple.second: returns the second element.triple.third: returns the third element.
val triple = Triple(1, 2, 3)
println(triple.first)
fun returnTriple() : Triple<Int, Int, Int> {
return Triple(1, 2, 3)
}
val triple = returnTriple()
println(triple) // prints (1, 2, 3)
Collections Intro
In Kotlin, all iterable data structures inherit from the Collection<T> abstract class, which means you can perform standard operation across all different concrete collections, even if they seem different.
Each collection has a mutable and immutable variant.
Here are the three main different collections in Kotlin, along with their mutable and immutable variants:
- lists: immutable variant is
List<T>, mutable isMutableList<T> - sets: immutable variant is
Set<T>, mutable isMutableSet<T> - maps: immutable variant is
Map<T>, mutable isMutableMap<T>
But since all these concrete collection subclasses inherit from the Collection<T> class, they also implement all the standard collections functionality:
- iterating over items: includes standard
forloop iteration and iteration methods. - element access: Includes standard element access via standardized methods and bracket-notation access.
- generator functionality: includes powerful methods like
.take()and.filter().
Here are all the immutable and mutable collections:
immutable collections
listOf()setOf()mapOf()arrayOf()
mutable collections
mutableListOf()mutableSetOf(): ordered sethashMapOf(): unordered mapmutableMapOf(): ordered maphashSetOf(): unordered set
Arrays, lists, and sets
Arrays, lists, and sets are the most similar in the collections family, and all have the exact same methods and ways for data access, adding elements, etc.
Maps
Maps are fundamentally a list of key-value pairs, which is a list of Pair<K, V>(key, value) data class instances under the hood.
Thus for things like iteration, filtering, and looping, the iteration variable is in reality a Pair<K, V>(key, value) instance and you should keep that in mind.
If you want to treat maps as normal iterables like arrays, lists, and sets, you can access a List<K> of the map's keys from a map.keys property.
Collections basic properties
collection.size: returns the size the collection
Collections data access
Here's how each data access method works and the differences between them for different collection instances:
collection.first(): returns the first element in the collectioncollection.last(): returns the last element in the collection
For bracket syntax, you have differences in all of them:
- array/list: Has indexed-based access to specific elements via
collection[index]syntax - set: cannot access individual elements since hash sets don't allow data access and are not indexed-based.
- map: Can access specific elements via
collection[key]syntax
Collections data modification
On all collections, you can modify elements, but only mutable variants of concrete collections can add or remove elements.
Here is how you can add or remove elements across collections:
- lists
mutableList.add<T>(value)mutableList.remove<T>(value)mutableList.removeAt<T>(index): able to remove an element at a specific index
- sets
mutableSet.add<T>(value)mutableSet.remove<T>(value)
- maps
map[key] = valuemap.put(key, value)map.remove(key)
Collections iteration
for-loop iteration
You can loop through all collections with a for/in loop, but for maps, the iteration variable will be a Pair<K, V> instance.
val languages: Set<String> = setOf("Java", "Kotlin", "Scala")
for (language in languages) {
println(language)
}
Since Pair<K, V> instances have a key and value property, keep that in mind, and you also have destructuring capabilities:
var testScores = mapOf(Pair("Junie", 87), Pair("Julie", 87), Pair("Sea", 87))
// loop over list of Pair<K, V> instances
for (record in testScores) {
println("user ${record.key} has score ${record.value}")
}
// destructured Pair<K, V> instances
for ((id, score) in testScores) {
println("user ${id} has score ${score}")
}
collection.forEach()
For lists, sets, and arrays, the collection.forEach() works as expected:
val readOnlyList = listOf(1, 2, 3)
val readOnlySet = setOf(1, 2, 3)
readOnlyList.forEach { println(it) }
readOnlySet.forEach { println(it) }
readOnlyList.forEach { num -> println(num) }
readOnlySet.forEach { num -> println(num) }
For maps, the iterating element is a Pair<K, V> instance, so to access the key and value you will have to use the pair.key or pair.value syntax.
val readOnlyMap = mapOf(1 to "a", 2 to "b", 3 to "c")
readOnlyMap.forEach { record -> println("${record.key} : ${record.value}") }
Or you can access the map.keys or map.values to get the keys array or values array respectively and then iterate over that.
collection.map()
The collection.map() iteration method returns a new list:
val readOnlyList = listOf(1, 2, 3)
val readOnlySet = setOf(1, 2, 3)
var doubleList = readOnlyList.map { it * 2}
doubleList = readOnlySet.map { it * 2 }
And for maps, again the iterating element is a Pair<K, V> instance, so to access the key and value you will have to use the pair.key or pair.value syntax:
var keys = readOnlyMap.map { it -> it.key }
collection.filter()
The collection.filter() iteration method returns a new list of only the elements that pass the predicate
val readOnlyList = listOf(1, 2, 3)
val readOnlySet = setOf(1, 2, 3)
var newlist = readOnlyList.filter { it > 2 }
newList = readOnlySet.filter { it > 2 }
And for maps, again the iterating element is a Pair<K, V> instance, so to access the key and value you will have to use the pair.key or pair.value syntax:
val readOnlyMap = mapOf(1 to "a", 2 to "b", 3 to "c")
readOnlyMap.filter { it.key > 1 }
.map { it -> it.key }
.sorted()
.forEach { key -> println(key) }
Other iteration methods:
collection.sorted(): returns the collection as sorted, works only on lists.collection.take(n): returns the first n elements in the collection
Sequences
Sequences in Kotlin are basically the Kotlin-version of Python generators.
The main difference is how they process data:
- Kotlin iterables (like List or Set) apply operations eagerly, creating intermediate collections for each step, which can be less efficient for large data sets.
- Sequences process elements lazily, applying all operations one-by-one per element until a result is reached, which can improve performance by avoiding unnecessary processing, especially with large collections or when only part of the data is needed.
There are two ways to create sequences:
val languages = listOf("kotlin", "java")
// method 1: create sequence from `sequenceOf<T>(vararg T)`
var sequence : Sequence<String> = sequenceOf(*languages.toTypedArray())
// method 2: get sequence from `Collection<T>.asSequence()`
sequence = languages.asSequence()
Why use sequences? This use case illustrates perfectly, where if we want only a small subset of the collection, it's a waste to use so much processing power to run all operations for each collection iteration method.
val languages = listOf("kotlin", "java")
// method 1: create sequence from `sequenceOf<T>(vararg T)`
var sequence : Sequence<String> = sequenceOf(*languages.toTypedArray())
// method 2: get sequence from `Collection<T>.asSequence()`
sequence = languages.asSequence()
sequence.filter { it.length > 1 }
.map {it.length}
.take(1)
When we use sequences, we just do the bare minimum, processing elements one at a time instead of loading entire collections into memory.
Array and list basics
element access
Same as always. arr[n] access the nth element of the array
var cars = arrayOf("Volvo", "BMW", "Ford", "Mazda")
println(cars[0]) // Volvo
check if element exists
Use the in operator to check if an element is in the array
if (value in arr) {
// ...
}
array iteration
Use the for-in loop to loop through the elements of an array.
for (element in arr) {
// ...
}
for (x in cars) {
println(x)
}
Or you can use the list iteration methods to loop over an array.
Spreading a collection
If you want to spread out a collection as arguments into a function that takes in a variable amount of arguments, then use the spread operator, * , in front of the collection name
add(1, 2, 3) // valid
val list = arrayOf(1, 2, 3)
add(*list) // also valid
List iteration
You have list iteration methods that accept lambda functions
var cars = arrayOf("Volvo", "BMW", "Ford", "Mazda")
// default `it` parameter
cars.forEach {
println(it)
}
forEach
Here is level 1, where we pass in a function
val names = mutableListOf<String>("John", "Paul", "George", "Ringo")
names.forEach(fun (name: String) {
println(name)
// code here
})
Here is level 2, where we use a lambda function but name our argument:
val names = mutableListOf<String>("John", "Paul", "George", "Ringo")
names.forEach {name ->
println(name)
// code here
}
Here is level 3, where we use a lambda function, and just the implicit parameter it .
val names = mutableListOf<String>("John", "Paul", "George", "Ringo")
names.forEach {
println(it)
// code here
}
forEachIndexed
Basically the same as forEach() , but in the lambda, you are now passed two arguments:
- index
- element
val names = mutableListOf<String>("John", "Paul", "George", "Ringo")
names.forEachIndexed {index, element ->
// code here
}
filter
var myList = (1..10).toList()
val filteredList = myList.filter { element -> element % 2 == 0 }
Arrays
Arrays are like lists except that they have no immutable variants and their size is decided at runtime.
Other that that, you can perform the same list iteration methods on arrays and instantiate them in roughly the same way:
// method 1: use `arrayOf()`
// smart enough to type as ints: Array<Int?>
var ints = arrayOf(1, 2, 3, 4, 5, null)
// method 2: instantiate array, specify size beforehand, make every element null
ints = arrayOfNulls<Int>(5)
// method 3: instantiate array size, lambda populates elements
ints = Array(5, { i -> (i + 1) * i })
for (int in ints) {
println(int)
}
for (i in 0 until ints.size) {
print(ints[i])
}
ints.forEach {int -> print(int) }
normal arrays
You can create arrays using the arrayOf() method, and pass in a comma separated list of values as arguments.
var myArr = arrayOf(val1, val2, val3, ...)
In general, there are three ways to create normal arrays:
- use
arrayOf<T>(varargs: T): instantiates fixed array with elements. - use
arrayOfNulls<T>(size: Int): Allocates array size, inits all elements to null - instantiate
Array<T>(size: Int, init: (index: Int) -> T): Allocates array size, for each element in array, execute lambda to get the initial value of that element.
// method 1: use `arrayOf()`
// smart enough to type as ints: Array<Int?>
var ints = arrayOf(1, 2, 3, 4, 5, null)
// method 2: instantiate array, specify size beforehand, make every element null
ints = arrayOfNulls<Int>(5)
// method 3: instantiate array size, lambda populates elements
ints = Array(5, { i -> (i + 1) * i })
intArray
You can create specialized, performant, compact integer arrays with intArrayOf() method.
List
Lists in Kotlin are instances of the List<T> collection subclass, and have mutable and immutable variants, where lists are by default immutable:
List<T>: immutable lists, created withlistOf()most commonlyMutableList<T>: mutable lists, created withmutableListOf()most commonly
// method 1: use `listOf()` to create list with elements
var list: List<Int> = listOf(1, 2, 3, 4, 5)
// method 2: instantiate List<T> class with lambda to populate elements from index
list = List<Int>(5, { index -> index + 1 })
// method 3: create an empty list
val emptyStringList = emptyList<String>()
// by default, list is immutable in kotlin, you have to use mutable variants
val mutableList = mutableListOf<String>()
mutableList.add("a")
mutableList.add("b")
mutableList.add("c")
mutableList[0] = "z"
mutableList.removeAt(0)
mutableList.remove("c")
Common list methods
These are the methods that are common to all List<T> subclasses, covering both mutable and immutable lists.
immutable list
We use the listOf() constructor and pass in all the values we want to put into the immutable list.
// Lists, we use List<Type> and the type of the collection inside the generic
// The literal uses the listOf constructor
// countries is IMMUTABLE!
val countries: List<String> = listOf("Argentina", "Brazil", "Canada", "Denmark")
You have three ways to create an immutable list in Kotlin:
listOf(): create a list with elements already defined- instantiate a
List<T>class: specify the list size and then supply a lambda that populates each element with a value. - use
emptyList<T>to create an empty list
// method 1: use `listOf()` to create list with elements
var list: List<Int> = listOf(1, 2, 3, 4, 5)
// method 2: instantiate List<T> class with lambda to populate elements from index
list = List<Int>(5, { index -> index + 1 })
// method 3: create an empty list
val emptyStringList = emptyList<String>()
mutable list
We use the mutableListOf() constructor to get back a MutableList instance, which has methods to add and remove elements
val cities: MutableList<String> = mutableListOf("Alameda", "Buenos Aires", "Cali")
cities.add("Dali")
sets
There are three types of set in Kotlin:
- set: an immutable, ordered set, instantiated with the
setOf()function. - hash set: a mutable, unordered set, instantiated with the
hashsetOf()function. - mutable set: a mutable, ordered set, instantiated with the
mutablesetOf()function
// method 1: use `setOf<T>(varargs: T)` to return Set<T> instance
val languages: Set<String> = setOf("Java", "Kotlin", "Scala")
for (language in languages) {
println(language)
}
println(languages.contains("Kotlin"))
val mutableLanguages = mutableSetOf("Java", "Scala")
// won't work, cuz set
mutableLanguages.add("Java")
normal immutable set
val languages: Set<String> = setOf("Java", "Kotlin", "Scala")
hash set
Use the hashSetOf() constructor to get back a traditional set.
val strings = hashSetOf("a", "b", "c", "c")
Set methods
set.elementAt(index): returns the element at the specified indexset.indexOf(element): returns the index of where the element was foundset.lastIndexOf(element): returns the last index of where the element was foundset.first(): returns the first element in the setset.last(): returns the last element in the setset.contains(element): returns a boolean, whether or not the set contains the specified element.set.isEmpty(): returns a boolean, true if the set is empty
For mutable sets like a mutable set or a hash set, you can use these methods:
set.add(element): adds the specified elementset.remove(element): removes the specified element from the set
maps
There are two types of maps in Kotlin:
- map: an immutable, ordered map, instantiated with the
mapOf()function. - hash map: a mutable, unordered map, instantiated with the
hashMapOf()function. - mutable map: a mutable, ordered map, instantiated with the
mutableMapOf()function.
// method 1: use Pair() class to define a key-value pair
var testScores = mapOf(Pair("Junie", 87), Pair("Julie", 87), Pair("Sea", 87))
// method 2: use infix function `to` as synctactic sugar over Pair() instance
testScores = mapOf("Junie" to 87, "Julie" to 87, Pair("Sea", 87))
testScores.containsKey("Junie")
testScores.containsValue(87)
for (record in testScores) {
println("user ${record.key} has score ${record.value}")
}
for ((id, score) in testScores) {
println("user ${id} has score ${score}")
}
testScores.keys.forEach { key -> println("user ${key} has score ${testScores[key]}") }
val mutableTestScores = testScores.toMutableMap()
// method 1: set key-value pair via standard bracket notation
mutableTestScores["Junie"] = 91
// method 2: add value with .put(), which is the old way
mutableTestScores.put("Julie2", 91)
mutableTestScores.putAll(testScores)
Creating maps
There are two ways to create maps:
- Creating a list of
Pair()instances and passing that into amapOf()method.
// method 1: use Pair() class to define a key-value pair
var testScores = mapOf(Pair("Junie", 87), Pair("Julie", 87), Pair("Sea", 87))
- using a
<key> to <value>syntactic sugar over creating a list of pair instances and passing that into amapOf()method.
// method 2: use infix function `to` as synctactic sugar over Pair() instance
testScores = mapOf("Junie" to 87, "Julie" to 87, Pair("Sea", 87))
map properties
val map = mapOf(1 to "One", 2 to "Two" , 3 to "Three", 4 to "Four")
map.keys // list of keys
map.values // list of values
map.size // returns map size
map.keys: returns a list of the keys in the mapmap.values: returns a list of the values in the mapmap.size: returns the length of the map
Hashmap
Use the hashMapOf() method to get back a hash map. You need to provide generics.
val map = hashMapOf<Int, String>(1 to "a", 2 to "b")
map.put(3, "c")
For mutable maps like a hash map, here are the methods you can use:
hashMap.put(key, value): add the specified key value pairhashMap.remove(key): removes the specified key from the map, along with its corresponding value.
map iteration
The map.forEach() method is a lambda method that takes in two args for the callback: key and value .
You get to iterate over all the keys and values in the map.
val map = mapOf(1 to "a", 2 to "b")
map.forEach { (key, value) ->
// have access to key and value
}
Classes
Constructors and class properties
Class properties
Classes in kotlin have public, private, and protected identifiers for methods and properties, as well as the this keyword.
class Person {
// property
public var id : Int = 0
// function - method
fun print() {
println("Person id: ${this.id}")
}
}
Adding methods
// constructor that accepts argumetns and makes them class properties
class Car(var brand: String, var model: String, var year: Int) {
// method
fun drive() {
println("Wrooom!")
}
// method with parameters
fun speed(maxSpeed: Int) {
println("Max speed is: " + maxSpeed)
}
}
instantiating the class
Much like Python, you do not use the new keyword. Instead you just call the class like a function.
var myObjInstance = MyClass()
Basic Constructors + properties
The most basic way to declare a constructor of a class is like a function signature:
class MyClass(arguments) {
// code here
}
Level 1
The most basic class representation has a single constructor in the class header, and then you set class properties inside the class, outside any block, like :
class Person(age: Int) {
var age = age
}
- Declare the constructor in the class header, and the necessary args that you want to accept in the constructor
class Person(age: Int) {
}
- Inside the class definition, set the class properties you want using the values from the constructor or not, which populates values for the properties on any object instances created from this class
class Person(age: Int) {
var age = age
}
To create properties in a class, just declare variables within the class header.
class MyClass {
var var1 = ""
var var2 = ""
}
- You can also add explicit access modifiers to class properties and methods, like
public,private,protected, etc. - You can then access those properties on an instance via dot-property syntax.
Level 2
Whatever arguments you pass in, if you declare them with var or val, they will automatically become class properties.
For example, we can reduce a lot of code for free:
// ❌ old, boring way
class Person(firstname: String, lastname: String) {
// accept constructor arguments, use them to set class properties.
private var firstname: String = firstname
private var lastname: String = lastname
private var age: Int = 0
}
// ✔️ LEVEL 2: set class properties implicitly in constructor
class Person(private var firstname: String, private var lastname: String) {
private var age: Int = 0
}
So in summary, the constructor is in the class header, and you have two ways of setting properties on a class:
- Method 1 (classic - constructor populating property values): You can accept arguments, and then create class properties and set them equal to the arguments passed in.
class MyClass(var1: Type1, var2: Type2, ...) {
// class properties here
var _var1 = var1
var _var2 = var2
// ... and so on
}
- Method 2 (syntactic sugar - set class properties in constructor signature): If you want to skip the assignment step, you can simply declare the arguments in the constructor with the
varorvalkeywords to get them automatically assigned as class properties, and then add access modifier keywords likeprivateorpublic.
class MyClass(private var var1: Type1, private var var2: Type2, ...) {
// nothing else to do
}
Secondary constructors
secondary constructors are constructor overloads you can provide to a class.
Secondary constructors let you provide alternative ways to construct an object. They use the constructor keyword and must call the primary constructor (or another secondary constructor):
class User(val id: Int, val name: String) {
constructor(name: String) : this(0, name) // calls primary with id=0
constructor() : this(0, "Unknown") // calls primary with defaults
}
// Now you can construct three ways:
User(1, "Alice") // primary
User("Bob") // secondary
User() // secondary
NOTE
Each secondary constructor delegates to the primary via this(...), ensuring initialization logic runs consistently.
We can provide constructor overloading by providing default values for the arguments in constructor overloads
class User(val id: Int) {
private var name= "Unnamed $id user"
// overloads with id = 0
constructor(name: String): this(0) {
this.name = name
}
// no overloads, accepts one more argument
constructor(id: Int, name: String): this(id) {
this.name = name
}
}
init blocks and constructor execution lifecycle
An init block is code that runs after an object is created, regardless of which constructor was used (primary or secondary)
The init block is used to run code after the constructor runs.
class Request(val url: String) {
private var timeout = 10;
init {
// runs after Request() is executed
print("fetching url $url")
}
}
It's declared with the init keyword and no parentheses:
class User(val id: Int, val name: String) {
init {
println("User created: $name")
}
}
Here's the lifecycle order explained:
- Constructor runs: When you invoke
User(1, "Alice"), that triggers the constructor to run, which then populates any declared class fields. initruns: theinitblock executes automatically after the object is instantiated, in the context of the object instance.
NOTE
You can also use init blocks alongside secondary constructors—the init block always runs after any constructor completes.
validation
You can perform validation of the created object instance inside the init block using the check function which takes in a callback to run the validation:
class Person(public var age: Int) {
init {
check (age > 0) {
"A person can only have a positive age"
}
}
}
The check(bool: Boolean, cb: () -> String) function will throw an IllegalStateException with the content of what's returned from the callback if the boolean condition is false.
Methods, getters, and setters
Methods, getters, and setters are all class methods, but here are the differences between them:
- methods: bonafide methods you can invoke from an object instance
- getters and setters: synctacic sugar over creating methods and hooking into the standard accessor and setters behind a class property.
- Whatever you can do in a normal method, you can do in a getter and setter.
Methods
Methods inside a class are declared with the fun keyword and you can use normal function syntax with it, including lambda syntax.
class Person(public var age: Int) {
fun printName() = println("Name: ${this.age}")
}
You also have access to some cool stuff with methods, (and by extension getters and setters) like built-in functions you can invoke within methods:
TODO(message: String): Throws aNotImplementederror, useful for documentation or creating stubs
Getters and setters
Inheritance
NOTE
Kotlin classes are final by default; to allow inheritance, use the open keyword on classes and methods.
By default, you cannot inherit from other classes.
- To make a class inheritable, you have to put the
open classkeyword modifier on it. - You can then inherit from that class by supplying default arguments for the superclass constructor.
Here's the basic syntax
open class ParentClass(arguments) {}
class ChildClass(arguments): ParentClass(arguments) {}
Let's walk through an example:
- To establish a class as a parent class children class should inherit from, use the
openkeyword.
open class MyParentClass(public val name: String, public var age: Int) {
}
- Then to inherit from a parent class, invoke the superclass constructor, passing the required constructor arguments for the primary or secondary constructor you want to invoke.
class MyChildClass(name: String, age: Int): MyParentClass(name, age) {
fun myFunction() {
println(this.name) // name is now inherited from the superclass
}
}
Overriding methods
To override methods, you must follow these steps:
- In the parent class, declare the method as
open - In the child class, declare that you want to override the parent class method with the
override funckeyword - In the overriden function, you gain access to the
superkeyword to refer to the parent class.
open class ParentClass {
open fun greet() {
print("Hello")
}
}
class ChildClass: ParentClass() {
override fun greet() {
super.greet()
print("Hi")
}
}
Interfaces
Interfaces are a way to enforce classes to implement certain methods and adhere to their methods signatures.
NOTE
It's a way to create reusable type contracts, and you can treat interfaces as both a type and as an abstract class.
Here is all the inheritance rules you need to know about interfaces:
- multiple implementation: classes can implement/inherit from one interface, or from multiple interfaces.
- interfaces can extend other interfaces: You have ultimate reusability by allowing interfaces to extend from other interfaces.
- interfaces can do whatever classes do (except have constructors): Interfaces are abstract classes without constructors. Remember them that way.
- Interfaces have abstract methods to be overriden and default methods
- Interfaces have abstract properties to be overriden and default properties
- Interfaces can attach getters and setters to those properties
On top of inheritance, interfaces can act as abstract class by providing default method implementations while also having the contract for abstract methods and properties
Here are all the rules you need to know about how interfaces create reusable type contracts over methods and properties:
- interfaces provide a contract for abstract methods and properties: interfaces describe the shape of a class with the methods and properties it is supposed to implement and override.
- interfaces do not have property initializers: If adding a property to an interface, you CANNOT provide a default value for the property. You can only override them
- interfaces can provide default method implementations: Interfaces can act like an abstract class by providing default method implementations
Implementing an interface
To implement an interface, simply type annotate the class as the interface.
interface Actions {
val name: String // abstract property to implement
fun buttfuck(): Unit
fun isOlder(age: Int) : Boolean
}
// type annotate as Actions interface
class Person(private var firstname: String, private var lastname: String) : Actions {
private var age: Int = 0
override val name: String = "${this.firstname} ${this.lastname}"
override fun epsteinfilesreveal() {
TODO("Not yet implemented")
}
override fun isOlder(age: Int) : Boolean {
return this.age > age
}
}
To implement from multiple interfaces, just do the type annotation, and use a commma to separate out the list of interfaces.
class MyClass : Interface1, Interface2 {
// code here
}
Overriding methods and default methods
We specify we want to override a method on the class inheriting from the interface with the override fun keyword
interface Listener {
fun listen()
fun introduce(age: Int, name: String) : String
}
class Human: Listener {
override fun listen() {
print("I'm listening!")
}
override fun introduce(age: Int, name: String): String {
return "My name is $name and I'm $age years old"
}
}
One thing you can do in kotlin is that interfaces are more like abstract classes now. You can have default method implementations that classes don't need to override.
However, if you want to override, just use the override fun syntax and call the super implementation of the function first.
interface MyInterface {
fun defaultFunc() {
// some default implementation
}
}
class MyClass : MyInterface {
override fun defaultFunc() {
super.defaultFunc()
// code here
}
}
Here's an example of using an interface sort of like an abstract class
- using default method implementation
// LEVEL 2: interface with default method implementation
interface Actions {
fun fuck() {
println("This guy is getting fucked")
}
}
class Person: Actions {
// no need to override function
}
- overriding default method implementation
// LEVEL 3: interface with default method implementation, override it
interface Actions {
fun fuck() {
println("This guy is getting fucked")
}
}
class Person: Actions {
override fun fuck() {
// 1. must call this first
super.fuck()
println("he now has a disease")
}
}
Overriding properties
You can declare properties on an interface, but you can't give them default implementations/values. You have to use classes for that
On a class that implements an interface, you can override properties and use getters and setters with them
interface Human {
val name: String
var age: Int
var introduction: String
}
class Student(_name: String, _age: Int) : Human {
override val name: String = _name
override var age: Int = _age
override var introduction: String
get() = "Hi, I am $name and I am $age years old"
set(value) {
println("$value is $value")
}
}
Checking if something is an interface
Because interfaces are basically the equivalent of an abstract class, you can use it with the is keyword to see if a variable is of a specific interface type:
interface MyInterface {}
if (someVariable is MyInterface) {
}
Objects and their use cases
In Kotlin, object declarations provide a thread-safe way to create singletons.
Objects have lazy initialization, meaning that they will only be instantiated and loaded into memory the first time they are accessed in code.
Objects in kotlin are similar to objects in javascript, where they are just containers for properties and methods.
NOTE
By convention, we titlecase the object identifier because objects are syntactic sugar over singleton classes.
object Rocky {
val paws = 4
fun meow() {
println("Meow!")
}
}
Objects can be used globally in kotlin, where they can be used to access global constants and methods easily.
Objects, interfaces, and anonymous objects
Objects can actually implement interfaces.
We can also create anonymous objects (non-global, scoped to a function) like so:
val cat = object {
val name = "Rocky"
val paws = 4
fun meow() {
println("Meow!")
}
}
These anonymous objects can implement interfaces:
interface Cat {
val name: String
val paws: Int
fun meow() {
println("$name is $paws")
}
}
fun main(vararg args: String) {
val rocky : Cat = object : Cat {
override val name = "Rocky"
override val paws = 4
override fun meow() {
super.meow() // call interface's implemention
println("Meow! I piss everywhere!")
}
}
}
Companion objects
Kotlin doesn't have static members like Java does. Instead, each class has a companion object—a single object instance attached to the class itself—where you put functions, constants, and variables that belong to the class rather than to individual instances.
Everything in the companion object is accessible via the class name (e.g., User) or on the Companion static property of a class (e.g., User.Companion), which returns a direct reference to that companion object.
Here are the general rules of how a companion object works:
- The companion object is created automatically when the class loads, even if you never create an instance.
- A companion object is Kotlin’s version of
static. Any properties or methods put inside acompanion objectwill belong to the class itself rather than the object instance. - The companion object is accessible and shared across all instances of that class.
Here are the rules of companion objects when creating them:
- complete class property and method access: when you create an instance of a class the companion object is attached to, that companion object has full access to even the private properties and methods of that instance.
class Person(private var firstname: String, private var lastname: String) {
private var greeting = "my name is $firstname $lastname"
companion object {
val people = mutableListOf(Person("Josh", "Allen"))
fun createPerson(fname: String, lname: String) : Person {
val person = Person(fname, lname)
// companion object feature: access private properties of instance
person.greeting = "Hello, I am ${person.firstName} ${person.lastName}"
}
}
}
// then call like this:
Person.createPerson("John", "Doe")
class Request(val url: String) {
private var timeout = 10;
init {
print("fetching url $url")
}
companion object {
fun createWithTimeout(url: String, timeout: Int): Request {
val request = Request(url)
request.timeout = timeout
return request
}
val methods = listOf("GET", "POST", "PUT", "DELETE")
}
}
Extension functions
Kotlin has a similar idea to adding methods to a class and by extension their object instances' prototypes. They are called extension functions, where this refers to the instance of the class we are extending the method from.
/*: * Extension functions
You can add functions to any Type! Careful OOP extremists!
*/
// extenion function
fun Int.millisForHours() = this * 60 * 60 * 1000
// extension property
val Int.isEven: Boolean
get() = this % 2 == 0
fun main(vararg args: String) {
println("I am also public and global")
println(4.millisForHours())
println(4.isEven)
}
Here is how you can create generic extension functions, which is very useful:
fun <T> T.log() = println(this)
Extension functions on a companion object
During runtime, we can also add extension functions to the companion object of a class, with this syntax:
ClassName.Companion.functionName () {
// code here
}
And here’s a basic example of adding a companion object extension function
class MyClass {
companion object {
fun greet() : Unit{
println("hi")
}
}
}
fun MyClass.Companion.fuckYou() : Unit {
println("fuck you")
}
fun main() {
MyClass.fuckYou()
}
Delegates
The delegate pattern is an OOP design pattern that leverages object composition to achieve the same code reuse as inheritance, but in a more flexible way.
Kotlin supports delegation in two primary ways:
- interface delegation: allows to to delegate the implementation of an interface to some other object implementing that interface
interface Logger {
fun log(tag: String, message: String)
}
class LoggerImpl : Logger {
override fun log(tag: String, message: String) {
println("$tag: $message")
}
}
// now DelegatedLogger.log() just executes LoggerImpl.log()
// think of it as using LoggerIMpl as the overriding class for the interface
// and DelegatedLogger inherits from LoggerImpl basically
class DelegatedLogger(private val delegate: Logger) : Logger by delegate {}
- property delegation: allows you to subscribe to changes on a class property or variable, running side effects whenever it changes:
class ViewModel {
// subscribes to run this side effect each time currentQuery changes
var currentQuery: String by Delegates.observable(initialValue = "") { property, oldValue, newValue ->
println("$oldValue -> $newValue")
}
fun search(query: String) {
currentQuery = query
}
}
Lazy delegates allow us to defer initialization of a property until it is first accessed, which you can do via a by lazy keyword then pass in an initialization lambda.
interface Logger {
fun log(tag: String, message: String)
}
class LoggerImpl : Logger {
override fun log(tag: String, message: String) {
println("$tag: $message")
}
}
// now DelegatedLogger.log() just executes LoggerImpl.log()
// think of it as using LoggerIMpl as the overriding class for the interface
// and DelegatedLogger inherits from LoggerImpl basically
class DelegatedLogger(private val delegate: Logger) : Logger by delegate {}
class ViewModel {
// only runs instantiation code once we try to access ViewModel.logger
val logger: Logger by lazy {
println("initializing logger")
DelegatedLogger(LoggerImpl())
}
fun search(query: String) {
logger.log(tag = "search", message = query)
}
}
Data classes
Data classes automatically generate useful methods for classes that hold data: toString() shows all properties and their values, equals() compares instances by their property values (not identity), and copy() lets you clone with selective property changes.
data class Product(val id: Int, val name: String, val price: Double)
val p1 = Product(1, "Laptop", 999.99)
val p2 = Product(1, "Laptop", 999.99)
println(p1) // Product(id=1, name=Laptop, price=999.99)
println(p1 == p2) // true (compares by property values, not identity)
println(p1 === p2) // false (different objects in memory)
Three key generated methods:
All data class instances have these three methods already implemented and working:
-
toString()— shows all properties and values instead of the useless default class name + hash code. -
equals()— compares two instances by their property values and is a method overload for the==operator.- Two
Productobjects with the sameid,name, andpriceare equal, even if they're separate instances.
- Two
-
copy()— clones the object with selective property changes that you can pass in via kwargs
val p1 = Product(1, "Laptop", 999.99)
val p2 = p1.copy(price = 799.99) // Same id and name, new price
println(p2) // Product(id=1, name=Laptop, price=799.99)
println(p1 == p2)
Destructuring data classes
You can destructure properties and methods from a data class just like javascript but using parentheses instead:
data class Cat(val name: String) {
val paws = 4
fun meow() {
println("$name is $paws")
}
}
fun main(vararg args: String) {
val rocky = Cat("Rocky")
val (name) = rocky
}
Sealed classes
Sealed classes are classes you can't instantiate. A sealed class in Kotlin is a special kind of class that lets you define a restricted hierarchy of subclasses.
- Each subclass in a sealed class can have its own unique properties, making it very flexible for modeling complex states, like UI states or operation results.
- The compiler knows all the possible subclasses, which helps ensure you handle every case when using them in your code.
NOTE
This makes sealed classes great for managing different states in applications with clear, type-safe code.
NOTE
They're typically used as containers for global utilities or constants, especially useful in Android where you can't have truly global functions.
sealed class Result {
companion object {
fun success(data: String): Success = Success(data)
fun error(message: String): Error = Error(message)
}
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading: Result()
}
All objects and nested classes within a sealed class must inherit from the sealed class. Here's what's going on:
- All nested classes/objects within a sealed class are stored as "states" on the sealed class.
sealed class UiState {
object Loading: UiState()
data class Loaded(val title: String): UiState()
class Error(val error: Throwable): UiState()
// sealed class gives advantage of forcing compiler to make this exhaustive
companion object Presentation {
fun render(state: UiState) = when (state) {
is Loading -> println("loading")
is Loaded -> println("loaded ${state.title}")
is Error -> println("error ${state.error}")
}
}
}
fun main(vararg args: String) {
var state: UiState = UiState.Loading
// 1. render loading, do work with state
UiState.Presentation.render(state)
state = UiState.Loaded(title = "Loaded, but oops, error!")
// 2. render loaded state, do work with state
UiState.Presentation.render(state)
state = UiState.Error(RuntimeException("Oops"))
// 3. render error state, do work with state
UiState.Presentation.render(state)
}
Enum classes
Enum classes are syntactic sugar over an Object that is meant to represent an enum by defining a fixed set of named values, where the members of an enum class are EnumEntry instances, and have the following properties and methods:
enumEntry.name: returns the name of the enum property, likeNorth.enumEntry.ordinal: returns the integer value of the enum property, starting from0then goes in ascending order.
enum class Direction {
North, East, South, West
}
// 1. all of these do the same thing, evaluate to string
println(Direction.North) // prints out "North"
println(Direction.North.name) // prints out "North"
println(Direction.North.toString()) // prints out "North"
println(Direction.valueOf(Direction.North.toString())) // prints out "North"
On the enum class itself (since it's just an object), you have these properties:
EnumClass.entries: returns aList<EnumEntry>collection back, so you can do all sorts of cool stuff with itEnumClass.values: returns aList<EnumEntry>collection back, so you can do all sorts of cool stuff with itEnumClass.valueOf(enumEntryName: String): returns theEnumEntryinstance of the corresponding enum entry by the string name
Level 2
Each value can have associated data—for example, Color(value: Int) lets each color constant hold an integer.
enum class Color(val rgb: Int) {
RED(0xFF0000)
}
Level 3
You can also add companion objects to enum classes, since they're just a class:
enum class Color(val value: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF);
companion object {
fun fromHex(hex: Int): Color? {
return values().find { it.value == hex }
}
}
}
Color.REDhas the value0xFF0000
Modules and third-party packages
How modules and top-level globals work
Kotlin follows Python, where a file is treated as a module, and each variable, function, and object in a file is automatically exported and available for other files to use.
Basically, a file is treated as syntactic sugar for a class, and top-level variables, constants, functions, and classes, are all considered "public" for use.
const val globalVal = "I am global"
public var globalMutableVar = "I am globally mutable"
private var localMutableVar = "I am private, only allowed to use in this file"
fun main(vararg args: String) {
println("I am also public and global")
}
There are three access modifiers you can set
public: makes the object able to be publicly used across the codebase.- By default, any top-level object, variable, or function has the
publicmodifier implicitly applied, and thus becomes a global.
- By default, any top-level object, variable, or function has the
private: the object is only available within the file, cannot be used publicly in other files.internal: makes the variable accessible within the given module but not the entire project.
IMPORTANT
Global variables are problematic because they are easy to use, even if convenient, because of these core reasons:
- Makes it harder to know what to import
- Destroys encapsulation if a variable was meant to only be used within a certain file.
Avoid using global variables, and instead either group them together within objects or declare them private or internal.
Useful, small modules
Random values
import kotlin.random.Random
fun getRandom(max: Int) = Random.nextInt(max)
Testing
- Set up your
build.gradle.ktsto have the JUnit dependency:
plugins {
kotlin("jvm") version "2.1.0"
application
}
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
}
tasks.test {
useJUnit()
}
application {
mainClass.set("MainKt")
}
- If not created, ensure you have a
settings.gradle.ktslike so:
rootProject.name = "mynewporj"
- Run the
./gradleew.bat testcommand:
.\gradlew.bat test --console=plain 2>&1 | Out-String
- Now you can write tests like this:
import kotlin.test.Test
import kotlin.test.assertEquals
class SampleTest {
@Test
fun `sorted lines are ordered`() {
val lines = listOf("c", "b", "a")
assertEquals(listOf("a", "b", "c"), lines.sorted())
}
@Test
fun `1 + 1 = 2`() {
assert(1 + 1 == 2)
}
@Test(expected = Throwable::class)
fun `illegal characters are not allowed`() {
val illegal = 8 / 0
}
}
Mocking data with mockito
Mockito is a third-party library that allows us to add mocks into our JUnit tests.
- Install mockito by adding it as a dependency
plugins {
kotlin("jvm") version "2.1.0"
application
}
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
testImplementation("org.mockito:mockito-core:5.11.0")
}
tasks.test {
useJUnit()
}
application {
mainClass.set("MainKt")
}
- Refresh the gradle with
CTRL + SHIFT + O

- Now you can add tests like so:
import org.mockito.Mockito
import kotlin.test.Test
import kotlin.test.assertEquals
class DummyClass {
fun hello() {
println("function implementation")
}
}
private fun runHello(dummyClass: DummyClass) {
println("starting Hello")
dummyClass.hello()
println("ending Hello")
}
class SampleTest {
@Test
fun `mock out DummyClass`() {
val mockedClass = Mockito.mock(DummyClass::class.java)
runHello(mockedClass)
// verify that DummyClass.hello() mock was called at least once
Mockito.verify(mockedClass).hello()
}
}
Async and Coroutines
Threads
The Thread class in Kotlin is a representation of a single thread.
There are three constructors you have for a Thread:
Thread() // creates thread with default name, default runnable
Thread("custom name") // creates thread with custom name, default runnable
Thread(runnable: Runnable) // creates thread with runnable impl
A Runnable class has one method, a run() method that you can override to create a custom implementation of what code is supposed to run in a thread.
Here are the methods available on a thread:
thread.start(): starts the thread in the backgroundthread.join(): blocks and waits for the thread to finish.
Here are the static properties available on the Thread class:
Thread.currentThread(): get the currently runningThreadinstance back.
Creating threads
There are two main ways to run threads and control them in Kotlin:
- Create a custom thread: creating a custom subclass that inherits from that class allows us to run our own code in a thread and control that thread.
class CustomThread: Thread("Custom Thread") {
override fun run() {
var parentThread = Thread.currentThread()
var parentThreadName = parentThread.name
super.run()
println("Custom Thread")
}
}
var thread = CustomThread()
thread.start()
- Create a custom runnable: create a custom
Runnablesubclass and then use it to instantiate a thread and then start it
class CustomRunnable: Runnable{
override fun run() {
println("Custom Runnable")
}
}
Thread(CustomRunnable()).start()
Thread pools
The problem with creating your own custom threads and running them is that threads are tied to CPU cores, and you can't go above 8 for the average laptop.
To avoid this limited resource problem, you should delegate resource management to a pool,and use Kotlin's thread pools instead, which shift the responsibility model:
- what pools handle: the creation and management of threads and deciding when to start running threads
- what you handle: providing a custom
Runnablesubclass implementation to create threads.
class CustomRunnable: Runnable{
override fun run() {
println("Custom Runnable")
}
}
There are two types of thread pools:
- single thread executor pool: only allows one thread to execute at a time, no concurrency:
// method 3
// only 8 threads is 8 CPUs, so use sparingly, and create a thread pool
// runs threads one at a time
val executor = Executors.newSingleThreadExecutor()
executor.submit(CustomRunnable())
- multi thread executor pool: Allows you to define a max concurrency for the number of concurrent threads you want running simultaneously
val multiThreadExecutor = Executors.newFixedThreadPool(4)
for (i in 1..10) {
multiThreadExecutor.submit(CustomRunnable())
}
Coroutines
Why coroutines
For async I/O where we want to initiate a network request without it being blocking, we need some way to offload that network request in a non-blocking way.
A traditional solution is to create more threads:
Thread 1 -> waits for network
Thread 2 -> handles another request
Thread 3 -> waits for database
Thread 4 -> ...
But threads are relatively expensive operating-system resources, and you can only spawn as many threads as there are CPU cores.
Coroutines give us another model:
Thread
|
| run coroutine A
|
| A reaches a waiting point
|
| run coroutine B
|
| B reaches a waiting point
|
| resume A
A coroutine can pause without blocking its thread.
That's the central idea.
A useful mental model is:
A thread is where code physically executes.
A coroutine is a resumable computation that can move on and off threads.
Coroutines in Kotlin
Coroutines are a routine that can be paused and resumed. They can be thought of lightweight threads, but unlike threads, they are not directly tied to the number of CPU cores.
Unlike traditional threads, coroutines can be suspended and resumed without blocking system resources, allowing multiple coroutines to run on a single thread efficiently
- Coroutines run code in parallel
- Multiple coroutines may be on a single thread
- Because coroutines can be paused and resumed, coroutine execution may jump threads
They run within scopes that manage their lifecycle and support cancellation, making it easier to handle concurrent tasks like API requests or background processing in a clean and resource-friendly way.
suspend functions
What is a suspend function?
Consider a suspend function. The keyword suspend means that this function is allowed to suspend the coroutine that is executing it.
It does not mean:
This function automatically runs asynchronously.
And it does not mean:
This function automatically creates a new thread.
Those are extremely common misunderstandings.
NOTE
A suspend function does nothing asynchronous whatsoever. It is simply a suspendable function.
Rules
Here are the main rules of suspend functions:
- you can only use
suspendfunctions in coroutines or othersuspendfunctions - sequential suspend calls are still sequential: In the context of a
suspendfunction body, the entire body executes in order. All nestedsuspendfunction invocations are treated as synchronous and are blocking within thesuspendfun, just like async/await.
IMPORTANT
suspend does not imply concurrency.
Why you can only use suspend functions in coroutines or other suspend functions
NOTE
You can only use suspend functions in coroutines or other suspend functions because how coroutines store execution state.
Suppose we had:
fun first() {
second()
}
suspend fun second() {
delay(1000)
}
Kotlin won't allow a regular function to call second() directly:
fun first() {
second() // error
}
Why?
Because second() might suspend.
If it suspends, Kotlin needs somewhere to store:
- where execution should resume
- local variables
- intermediate state
- what should happen after the suspended call
A normal function call doesn't participate in that coroutine machinery, but a suspend function does.
Coroutine state deep dive
Suppose:
suspend fun example() {
println("A")
delay(1000)
println("B")
}
Conceptually, Kotlin needs to remember something like:
state = BEFORE_DELAY
When delay() suspends, execution leaves the function.
Later Kotlin resumes it with something conceptually like:
state = AFTER_DELAY
and continues at:
println("B")
You can roughly imagine the compiler transforming suspend functions into state machines.
Not literally this code, but conceptually:
when (state) {
0 -> {
println("A")
state = 1
delay(...)
return
}
1 -> {
println("B")
}
}
Coroutines models the event-loop in node, where suspending functions "pause" the execution of a currently executing coroutine and then execute in the background (be it a network or database I/O call) and then move to the next coroutine scheduled for execution.
NOTE
This transformation is one reason coroutines can be much lighter-weight than blocking one OS thread per operation.
Suspension vs blocking
This distinction matters enormously.
Compare:
Thread.sleep(1000)
with:
delay(1000)
Thread.sleep blocks the thread:
Thread
|
| sleep....................................
|
| continues
Nobody else can use that thread during the sleep.
delay suspends the coroutine:
Thread
|
| Coroutine A runs
| A calls delay()
| A suspends
|
| Coroutine B may run here
|
| Coroutine A becomes ready
| A resumes
So:
delay()
does not mean "sleep this thread."
It means:
Suspend this coroutine and arrange for it to become runnable later.
That leads directly to a deeper idea.
Using suspend functions
A good example of a suspend function is delay(ms: Int).
suspend fun downloadUser(): User {
delay(1000)
return User("Alice")
}
import kotlinx.coroutines.*
fun main() {
println("Main thread started")
GlobalScope.launch {
delay(1000)
println("coroutine: delayed")
delay(1000)
println("coroutine: delayed again")
}
Thread.sleep(3000)
println("Main thread finished")
}
The code above will print out the following output:
Main thread started
coroutine: delayed
coroutine: delayed again
Main thread finished
You can create your own suspend function using the suspend fun keyword.
suspend fun networkCall() : String {
delay(1000) // simulate network call
return "{'success': true}"
}
fun main() {
println("Main thread started")
GlobalScope.launch {
val responseData = networkCall()
println("data from network call: ${responseData}")
}
Thread.sleep(3000)
println("Main thread finished")
}
As you can see, we will automatically await the return result of a suspend function when called inside a coroutine or in another suspend function. This is nothing special. Coroutines are automatically awaited within a scope.
Coroutine builders
suspend functions do not create coroutines. You need a coroutine builder.
There are five possible coroutine builders
launch
async
runBlocking
coroutineScope
supervisorScope
Coroutine builders can only be invoked inside a scope.
launch: launches coroutines that execute asynchronously, and jump execution to other coroutines when asuspendfunction is invoked somewhere in the launched coroutine body.- use case: when you want to perform async work but you don't need to return value
async: launches coroutine that immediately returnDeferrableinstances so you can access the return value of a coroutine- use case: when you want to retrieve a value from an async I/O process.
launch
The launch coroutine builder starts a coroutine and returns a Job instance:
val job: Job = launch {
delay(1000)
println("Finished")
}
Here are the methods you have available on a job:
job.cancel(): synchronously cancels the execution of a job.job.join(): blocks and waits synchronously for the job to finish
suspend fun jobExample(): Unit = runBlocking {
// 1. recognize coroutine, queue to execute
val job = launch {
// 3. suspends and finishes execution due to job.join()
delay(1000)
println("Finished")
}
// 2. wait synchronously for coroutine to finish
job.join()
// 4. synchronously blocked by job.join()
println("After coroutine")
}
The main use case for launch is to perform some asynchronous work that does not return a value, since the return value of launch is Job<Unit>.
Here's an appropriate mental model:
launch: start a coroutine whose completion matters, but which does not produce a value for me.
async
async is very similar to launch, except it is designed to produce a result.
Async/await is a way to parallelize suspend functions and coroutines, like if you want to parallelize network requests.
- Wrap a suspension call in the
async {}lambda. The return value will be aDefferableinstance that immediately kicks off coroutine execution in the background. - The coroutine
deferrable.await()runs blocking until you can get the return of the suspension function
suspend fun networkRequest1() : String {
delay(2000)
return "{success: true}"
}
suspend fun networkRequest2() : String {
delay(2000)
return "{success2: true}"
}
GlobalScope.launch {
// 1. get deferrables of the suspend functions
val deferred1 = async {networkRequest1()}
val deferred2 = async {networkRequest2()}
// 2. await the suspend functions
println(deferred1.await())
println(deferred2.await())
println("await finished")
}
Or you can await them in parallel
GlobalScope.launch {
// 1. get deferrables of the suspend functions
val deferred1 = async {networkRequest1()}
val deferred2 = async {networkRequest2()}
// 2. await the suspend functions
awaitAll(deferred1, deferred2)
println("await finished")
}
launch vs async
launch | async | |
|---|---|---|
| Returns | Job | Deferred<T> |
| Produces result | No meaningful result | Yes |
| Wait using | join() | await() |
| Typical purpose | Start some work | Start computation that returns a value |
A useful rule:
- If you don't need a return value, prefer
launch. - If you're intentionally running a computation concurrently and need its value later, use
async.
Scopes and structured concurrency
Coroutines have two main features:
- structured concurrency: the concept where concurrency is scoped to a specific coroutine scope, and you can nest concurrency, allowing you to do things like launch child coroutines within the context of a spawning parent coroutine.
- coroutine cancellation: Kotlin allows you to cancel a coroutine or a coroutine scope.
- Typically, cancelling a coroutine cancels any child coroutines and scopes as well.
A coroutine scope is a concrete subclass of the CoroutineScope class, and is essentially an object that contains a CoroutineContext.
Its simplified definition looks conceptually like:
interface CoroutineScope {
val coroutineContext: CoroutineContext
}
A scope gives coroutine builders a context and lifecycle relationship.
- A scope invoking a coroutine builder creates a coroutine.
- coroutine builders invoked within a coroutine body created child coroutines.
// create parent coroutine associated with scope
scope.launch {
// create child coroutine/scope
launch {
// ...
}
}
Here is how scope works in detail:
- When you cancel the scope, you cancel all coroutines launched by that scope.
- When a scope launches a coroutine, by default it will run on the same thread the parent scope is in
- When a coroutine is launched within a scope, it will finish before the scope ends (coroutines are blocking in scopes)
- Within a scope, coroutines by default run blocking and in sequential order. To change this, you can launch child scopes.
NOTE
We can create custom threads and have scopes run in those threads instead, as we'll see in the next section
Scope types
You have three coroutine scopes, GlobalScope, coroutineScope, and runBlocking, that have different behaviors as to how they control child scopes and coroutines.
-
GlobalScope: Lives for the entire application lifetime. Use it only for coroutines that should persist throughout your program, but be cautious as it doesn't tie coroutines to any specific lifecycle.
-
runBlocking scope: Creates a scope that blocks the current thread until all coroutines inside it complete. It's useful in main functions or testing to ensure coroutines finish before the program exits.
-
Child scopes: Scopes can be nested to create parent-child relationships, helping manage coroutines hierarchically. Canceling a parent scope cancels all its child coroutines, which is great for cleaning up work tied to specific components or requests.
Basic coroutine with runBlocking
- Install the dependencies
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
This is an example of launching a coroutine with GlobalScope.launch, which means that the coroutine will persist for the lifetime of the app unless we programmatically cancel it.
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun main(vararg args: String) {
GlobalScope.launch {
println("This runs in background,")
}
println("program exits!")
}
However, what gets printed here is just "program exits" because the coroutine is launched asynchronously and thus is not blocking on the main thread, and the program exits before the coroutine finishes.
To make sure we wait for the coroutine (async code) to finish, we have to treat the coroutine as if it were running synchronously, and the way to do that is with the runBlocking scope:
In this version, "This runs in background" is printed out before "program exits"
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main(vararg args: String) : Unit = runBlocking {
// 1. called first
println("This runs in background,")
// 2. called second
println("program exits!")
}
In this version however, we launch a child scope so "program exits" prints first, but the runBlocking scope waits until all children coroutine execution is completed, so the program doesn't exit until "This runs in background" is printed.
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun main(vararg args: String) : Unit = runBlocking {
launch {
// runs second
println("This runs in background,")
}
// runs first
println("program exits!")
}
Coroutine contexts
By default, all scopes and thus all coroutines are launched in the main thread.
Coroutine contexts are what you use to change which thread a scope or coroutine runs on.
There are two ways to achieve this:
- Method 1 - custom thread
- Method 2 - dispatchers: the more common and easy way to launch coroutines on a different thread.
Dispatchers
Here is an example of using dispatchers to bounce between threads.
This code:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
fun getCurrentThreadName() = Thread.currentThread().name
// 0. launch runBlocking scope on main thread
fun main(vararg args: String) : Unit = runBlocking {
// 2. launch child scope on I/O thread
launch(Dispatchers.IO) {
// 2a. runs coroutine in I/O thread
println("This is some network call on a worker thread ${getCurrentThreadName()}")
// 3. launch coroutine on same thread runBlocking is on (main)
withContext(this@runBlocking.coroutineContext) {
println("some UI update that should be run on the same thread runBlocking runs in ${getCurrentThreadName()}")
}
}
// 1. coroutine runs blocking within runBlocking, on main thread
println("program exits on thread ${getCurrentThreadName()}")
}
Produces this output:
program exits on thread main
This is some network call on a worker thread DefaultDispatcher-worker-1
some UI update that should be run on the same thread runBlocking runs in main
Building CLI apps
Accepting arguments
The main.kt file must have the main(vararg args: String) analog to Java so it acts as the main entrypoint for running a kotlin project and then passing arguments to it via the command line.
The args array is just a string list of the CLI arguments passed when running the project.
/**
* args[0] - first CLI arg
*/
fun main(vararg args: String) {
println("first argument: ${args[0]}")
}
To enable passing CLI values to the entrypoint in IntelliJ, follow these steps:
- Edit the run configuration for the project

- Add the arguments you want to pass

fun main(vararg args: String) {
if (args.isEmpty()) {
println("Usage: pass something plz")
}
println("first argument: ${args[0]}")
}
Accepting user input
Accept user input with the readln() function:
print("Enter your name: ")
// ensures non-null input
val name = readln()
print("Enter your age: ")
val age = readln().toInt()
Files
All files in Kotlin are represented through the File(filepath: String) class.
val scoresFile = File("scores.txt")
if (!scoresFile.exists()) {
scoresFile.createNewFile()
}
scoresFile.writeText("$name $age\n")
scoresFile.forEachLine { line -> println(line) }
scoresFile.readLines().sorted().forEach { println(it) }
val outputFile = File("sorted-scores.txt")
outputFile.createNewFile()
outputFile.toPath().writeLines(scoresFile.readLines().sorted())
File creation
- Create a
Fileinstance:
val scoresFile = File("scores.txt")
- If the file instance doesn't exist, checking via
file.exists(), then create it:
if (!scoresFile.exists()) {
scoresFile.createNewFile()
}
In summary:
file.exists(): returns a boolean for whether or not the file existsfile.createNewFile(): synchronously creates the file.
Reading file content
val scoresFile = File("scores.txt")
scoresFile.forEachLine { line -> println(line) }
scoresFile.readLines().sorted().forEach { println(it) }
You have two different ways of reading the contents of a file, both of which involve going line by line via a sequence for better memory performance:
file.forEachLine(lambda: (line: String) -> Unit): for each line in the file, execute the lambda on it.file.readLines(): returns aSequence<String>representing the sequence of all the lines in the file, and then you can use it as a normal sequence.